feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+668
View File
@@ -0,0 +1,668 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const { logJSON } = require('./logger')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'ai-lib.db')
const db = new Database(dbPath)
db.pragma('foreign_keys = ON')
db.pragma('synchronous = NORMAL')
db.pragma('journal_mode = WAL')
const cfgPath = path.join(process.cwd(), 'config', 'ai-lib.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex') }, null, 2))
let _cfg = null
const getConfig = () => {
if (!_cfg) _cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cfg
}
const passphrase = process.env.AILIB_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
let _key = null
const deriveKey = () => {
if (!_key) {
const salt = Buffer.from(getConfig().machine_salt || '', 'utf-8')
_key = crypto.scryptSync(passphrase, salt, 32)
}
return _key
}
const enc = (plain) => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct: ct.toString('base64'), iv: iv.toString('base64'), tag: tag.toString('base64') }
}
const dec = (ct, iv, tag) => {
if (!ct) return ''
try {
const key = deriveKey()
const _iv = Buffer.from(String(iv||''), 'base64')
const _tag = Buffer.from(String(tag||''), 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm', key, _iv)
decipher.setAuthTag(_tag)
const out = Buffer.concat([decipher.update(Buffer.from(String(ct), 'base64')), decipher.final()])
return out.toString('utf-8')
} catch (e) {
console.error('Decryption failed:', e.message)
return '[Decryption Failed]'
}
}
db.exec(
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pass_salt TEXT NOT NULL,
pass_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT,
name_iv TEXT,
name_tag TEXT,
intro_enc TEXT,
intro_iv TEXT,
intro_tag TEXT,
major_category_enc TEXT,
major_category_iv TEXT,
major_category_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
try {
db.prepare('SELECT major_category_enc FROM systems LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE systems ADD COLUMN major_category_enc TEXT`)
db.exec(`ALTER TABLE systems ADD COLUMN major_category_iv TEXT`)
db.exec(`ALTER TABLE systems ADD COLUMN major_category_tag TEXT`)
}
}
db.exec(
`CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id INTEGER NOT NULL,
name_enc TEXT, name_iv TEXT, name_tag TEXT,
desc_enc TEXT, desc_iv TEXT, desc_tag TEXT,
server_addr_enc TEXT, server_addr_iv TEXT, server_addr_tag TEXT,
server_port INTEGER,
code_type_enc TEXT, code_type_iv TEXT, code_type_tag TEXT,
deploy_method_enc TEXT, deploy_method_iv TEXT, deploy_method_tag TEXT,
software_versions_enc TEXT, software_versions_iv TEXT, software_versions_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(system_id) REFERENCES systems(id) ON DELETE CASCADE
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_id INTEGER NOT NULL,
note_type TEXT NOT NULL,
content_enc TEXT, content_iv TEXT, content_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(module_id) REFERENCES modules(id) ON DELETE CASCADE
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename_enc TEXT, filename_iv TEXT, filename_tag TEXT,
mime_enc TEXT, mime_iv TEXT, mime_tag TEXT,
size INTEGER,
store_enc TEXT, store_iv TEXT, store_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);`
)
db.exec(`CREATE INDEX IF NOT EXISTS idx_modules_system_id ON modules(system_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_module_id ON notes(module_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_attachments_note_id ON attachments(note_id)`)
const authCookieName = 'ai_lib_auth'
const login = (u, p) => {
const row = db.prepare(`SELECT * FROM users WHERE username=?`).get(u)
if (!row) return { ok:false, error:'user_not_found' }
const salt = Buffer.from(row.pass_salt, 'base64')
const hash = crypto.scryptSync(p, salt, 32).toString('base64')
if (hash !== row.pass_hash) return { ok:false, error:'wrong_password' }
return { ok:true, user: { id: row.id, username: row.username } }
}
const changePassword = (u, oldp, newp) => {
const r = login(u, oldp)
if (!r.ok) return r
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(newp, salt, 32)
db.prepare(`UPDATE users SET pass_salt=?, pass_hash=? WHERE id=?`).run(salt.toString('base64'), hash.toString('base64'), r.user.id)
return { ok:true }
}
(() => {
try {
const count = db.prepare(`SELECT COUNT(*) AS c FROM users`).get().c
if (count === 0) {
const defaultPass = process.env.AILIB_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(defaultPass, salt, 32)
db.prepare(`INSERT INTO users (username, pass_salt, pass_hash) VALUES (?, ?, ?)`).run('dev_al', salt.toString('base64'), hash.toString('base64'))
}
} catch {}
})()
const sign = (obj) => {
const key = deriveKey()
const mac = crypto.createHmac('sha256', key)
mac.update(JSON.stringify(obj))
return mac.digest('base64')
}
const verify = (payload, sig) => {
const key = deriveKey()
const mac = crypto.createHmac('sha256', key)
mac.update(JSON.stringify(payload))
const expect = mac.digest('base64')
return expect === sig
}
const attachmentsDir = path.join(process.cwd(), 'uploads', 'ai-lib', 'attachments')
if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true })
const saveAttachmentChunk = (note_id, filename, mime, buffer) => {
const originalName = String(filename||'file.bin')
const efn = enc(originalName)
const emime = enc(String(mime||'application/octet-stream'))
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(), iv)
const ct = Buffer.concat([cipher.update(buffer), cipher.final()])
const tag = cipher.getAuthTag()
const outName = `${Date.now()}-${Math.random().toString(36).slice(2)}.bin`
const full = path.join(attachmentsDir, outName)
fs.writeFileSync(full, Buffer.concat([iv, tag, ct]))
const estore = enc(full)
const info = db.prepare(`INSERT INTO attachments (note_id, filename_enc, filename_iv, filename_tag, mime_enc, mime_iv, mime_tag, size, store_enc, store_iv, store_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
parseInt(note_id,10), efn.ct, efn.iv, efn.tag, emime.ct, emime.iv, emime.tag, buffer.length, estore.ct, estore.iv, estore.tag
)
return info.lastInsertRowid
}
const listSystems = () => {
const rows = db.prepare(`SELECT * FROM systems ORDER BY id ASC`).all()
return rows.map(r => ({
id:r.id,
name: dec(r.name_enc,r.name_iv,r.name_tag),
intro: dec(r.intro_enc,r.intro_iv,r.intro_tag),
major_category: dec(r.major_category_enc,r.major_category_iv,r.major_category_tag),
created_at:r.created_at,
updated_at:r.updated_at
}))
}
const createSystem = (payload) => {
const en = enc(payload.name||'')
const ei = enc(payload.intro||'')
const em = enc(payload.major_category||'')
const info = db.prepare(`INSERT INTO systems (name_enc, name_iv, name_tag, intro_enc, intro_iv, intro_tag, major_category_enc, major_category_iv, major_category_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(en.ct, en.iv, en.tag, ei.ct, ei.iv, ei.tag, em.ct, em.iv, em.tag)
return info.lastInsertRowid
}
const updateSystem = (payload) => {
const id = parseInt(payload.id,10)
const en = enc(payload.name||'')
const ei = enc(payload.intro||'')
const em = enc(payload.major_category||'')
db.prepare(`UPDATE systems SET name_enc=?, name_iv=?, name_tag=?, intro_enc=?, intro_iv=?, intro_tag=?, major_category_enc=?, major_category_iv=?, major_category_tag=?, updated_at=datetime('now') WHERE id=?`).run(en.ct, en.iv, en.tag, ei.ct, ei.iv, ei.tag, em.ct, em.iv, em.tag, id)
}
const deleteSystem = (id) => {
db.prepare(`DELETE FROM systems WHERE id=?`).run(parseInt(id,10))
}
const listModules = (system_id) => {
const rows = db.prepare(`SELECT * FROM modules WHERE system_id=? ORDER BY id ASC`).all(parseInt(system_id,10))
return rows.map(r => ({
id:r.id, system_id:r.system_id,
name: dec(r.name_enc,r.name_iv,r.name_tag),
description: dec(r.desc_enc,r.desc_iv,r.desc_tag),
server_addr: dec(r.server_addr_enc,r.server_addr_iv,r.server_addr_tag),
server_port: r.server_port,
code_type: dec(r.code_type_enc,r.code_type_iv,r.code_type_tag),
deploy_method: dec(r.deploy_method_enc,r.deploy_method_iv,r.deploy_method_tag),
software_versions: dec(r.software_versions_enc,r.software_versions_iv,r.software_versions_tag),
created_at:r.created_at, updated_at:r.updated_at
}))
}
const createModule = (payload) => {
const sid = parseInt(payload.system_id,10)
const en = enc(payload.name||'')
const ed = enc(payload.description||'')
const ea = enc(payload.server_addr||'')
const ct = parseInt(payload.server_port||'0',10)
const ec = enc(payload.code_type||'')
const dm = enc(payload.deploy_method||'')
const sv = enc(payload.software_versions||'')
const info = db.prepare(`INSERT INTO modules (system_id, name_enc, name_iv, name_tag, desc_enc, desc_iv, desc_tag, server_addr_enc, server_addr_iv, server_addr_tag, server_port, code_type_enc, code_type_iv, code_type_tag, deploy_method_enc, deploy_method_iv, deploy_method_tag, software_versions_enc, software_versions_iv, software_versions_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sid, en.ct,en.iv,en.tag, ed.ct,ed.iv,ed.tag, ea.ct,ea.iv,ea.tag, ct, ec.ct,ec.iv,ec.tag, dm.ct,dm.iv,dm.tag, sv.ct,sv.iv,sv.tag)
return info.lastInsertRowid
}
const updateModule = (payload) => {
const id = parseInt(payload.id,10)
const en = enc(payload.name||'')
const ed = enc(payload.description||'')
const ea = enc(payload.server_addr||'')
const ct = parseInt(payload.server_port||'0',10)
const ec = enc(payload.code_type||'')
const dm = enc(payload.deploy_method||'')
const sv = enc(payload.software_versions||'')
db.prepare(`UPDATE modules SET name_enc=?, name_iv=?, name_tag=?, desc_enc=?, desc_iv=?, desc_tag=?, server_addr_enc=?, server_addr_iv=?, server_addr_tag=?, server_port=?, code_type_enc=?, code_type_iv=?, code_type_tag=?, deploy_method_enc=?, deploy_method_iv=?, deploy_method_tag=?, software_versions_enc=?, software_versions_iv=?, software_versions_tag=?, updated_at=datetime('now') WHERE id=?`).run(en.ct,en.iv,en.tag, ed.ct,ed.iv,ed.tag, ea.ct,ea.iv,ea.tag, ct, ec.ct,ec.iv,ec.tag, dm.ct,dm.iv,dm.tag, sv.ct,sv.iv,sv.tag, id)
}
const deleteModule = (id) => {
db.prepare(`DELETE FROM modules WHERE id=?`).run(parseInt(id,10))
}
const listNotes = (module_id) => {
const rows = db.prepare(`SELECT * FROM notes WHERE module_id=? ORDER BY id ASC`).all(parseInt(module_id,10))
return rows.map(r => ({ id:r.id, module_id:r.module_id, note_type:r.note_type, content: dec(r.content_enc,r.content_iv,r.content_tag), created_at:r.created_at, updated_at:r.updated_at }))
}
const createNote = (payload) => {
const mid = parseInt(payload.module_id,10)
const typ = String(payload.note_type||'')
const c = enc(payload.content||'')
const info = db.prepare(`INSERT INTO notes (module_id, note_type, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?)`).run(mid, typ, c.ct, c.iv, c.tag)
return info.lastInsertRowid
}
const updateNote = (payload) => {
const id = parseInt(payload.id,10)
const typ = String(payload.note_type||'')
const c = enc(payload.content||'')
db.prepare(`UPDATE notes SET note_type=?, content_enc=?, content_iv=?, content_tag=?, updated_at=datetime('now') WHERE id=?`).run(typ, c.ct, c.iv, c.tag, id)
}
const deleteNote = (id) => {
db.prepare(`DELETE FROM notes WHERE id=?`).run(parseInt(id,10))
}
const listAttachments = (note_id) => {
const rows = db.prepare(`SELECT * FROM attachments WHERE note_id=? ORDER BY id ASC`).all(parseInt(note_id,10))
return rows.map(r => ({ id:r.id, note_id:r.note_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at }))
}
const decryptAttachmentBuffer = (row, rawBuffer) => {
if (!row) return null
const filename = dec(row.filename_enc, row.filename_iv, row.filename_tag) || 'file.bin'
const mime = dec(row.mime_enc, row.mime_iv, row.mime_tag) || 'application/octet-stream'
const raw = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.alloc(0)
if (raw.length < 28) return null
const iv = raw.slice(0,12)
const tag = raw.slice(12,28)
const ct = raw.slice(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(), iv)
decipher.setAuthTag(tag)
const data = Buffer.concat([decipher.update(ct), decipher.final()])
return { filename, mime, data }
}
const getAttachmentDataByStoredPath = (storedPath, rawBuffer) => {
const expected = path.resolve(String(storedPath || ''))
if (!expected) return null
const rows = db.prepare(`SELECT * FROM attachments ORDER BY id DESC`).all()
for (const r of rows) {
const full = path.resolve(dec(r.store_enc, r.store_iv, r.store_tag) || '')
if (!full || full !== expected) continue
const fileBuf = Buffer.isBuffer(rawBuffer) ? rawBuffer : (fs.existsSync(full) ? fs.readFileSync(full) : null)
if (!fileBuf) return null
return decryptAttachmentBuffer(r, fileBuf)
}
return null
}
db.exec(
`CREATE TABLE IF NOT EXISTS major_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT, name_iv TEXT, name_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
try {
const count = db.prepare('SELECT COUNT(*) AS c FROM major_categories').get().c
if (count === 0) {
const defaults = ['Trae研发', '信息化', '智能硬件']
const stmt = db.prepare('INSERT INTO major_categories (name_enc, name_iv, name_tag) VALUES (?, ?, ?)')
defaults.forEach(name => {
const en = enc(name)
stmt.run(en.ct, en.iv, en.tag)
})
}
} catch (e) {
console.error('Init major_categories failed:', e)
}
const listMajorCategories = () => {
const rows = db.prepare('SELECT * FROM major_categories ORDER BY id ASC').all()
return rows.map(r => ({
id: r.id,
name: dec(r.name_enc, r.name_iv, r.name_tag),
created_at: r.created_at,
updated_at: r.updated_at
}))
}
const createMajorCategory = (name) => {
const en = enc(name)
const info = db.prepare('INSERT INTO major_categories (name_enc, name_iv, name_tag) VALUES (?, ?, ?)').run(en.ct, en.iv, en.tag)
return info.lastInsertRowid
}
const updateMajorCategory = (id, name) => {
const en = enc(name)
db.prepare("UPDATE major_categories SET name_enc=?, name_iv=?, name_tag=?, updated_at=datetime('now') WHERE id=?").run(en.ct, en.iv, en.tag, parseInt(id, 10))
}
const deleteMajorCategory = (id) => {
db.prepare('DELETE FROM major_categories WHERE id=?').run(parseInt(id, 10))
}
const bindRoutes = (app) => {
const parseCookie = (cookieStr) => {
const out = {}
String(cookieStr||'').split(/;\s*/).forEach(p=>{ const idx=p.indexOf('='); if (idx>0) out[p.slice(0,idx)] = decodeURIComponent(p.slice(idx+1)) })
return out
}
const mustAuth = (req, res) => {
try {
const cookies = parseCookie(req.headers.cookie||'')
const raw = cookies[authCookieName]
if (!raw) return false
let obj = null
try { obj = JSON.parse(raw) } catch {}
if (!obj || !obj.payload || !obj.sig) return false
return verify(obj.payload, obj.sig)
} catch { return false }
}
app.post('/api/ai-lib/auth/login', (req, res) => {
const body = req.body||{}
const r = login(String(body.username||''), String(body.password||''))
if (!r.ok) return res.status(401).json(r)
const payload = { uid: r.user.id, u: r.user.username, iat: Date.now() }
const sig = sign(payload)
res.cookie(authCookieName, JSON.stringify({ payload, sig }), { httpOnly: true, sameSite:'lax', maxAge: 86400000 })
logJSON('ai-lib.auth.login', { username: r.user.username }, 'ai-lib')
res.json({ ok:true, user: r.user })
})
app.post('/api/ai-lib/auth/logout', (req, res) => {
res.clearCookie(authCookieName)
res.json({ ok:true })
})
app.get('/api/ai-lib/auth/local_creds', (req, res) => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
})
}
} catch {}
let content = null
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
if (!content) return res.json({ ok: true, creds: null })
const creds = {}
content.split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const idx = t.indexOf('=')
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
})
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
if (serverToken) {
const clientToken = String(req.query.token || '')
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
}
const u = creds.AILIB_USERNAME || ''
const p = creds.AILIB_PASSWORD || ''
if (!u && !p) return res.json({ ok: true, creds: null })
res.json({ ok: true, creds: { username: u, password: p } })
} catch { res.json({ ok: true, creds: null }) }
})
app.post('/api/ai-lib/auth/change', (req, res) => {
const body = req.body||{}
const r = changePassword(String(body.username||''), String(body.old_password||''), String(body.new_password||''))
if (!r.ok) return res.status(400).json(r)
res.json({ ok:true })
})
app.get('/api/ai-lib/debug/counts', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' });
try {
const systems = db.prepare(`SELECT COUNT(*) AS c FROM systems`).get().c;
const modules = db.prepare(`SELECT COUNT(*) AS c FROM modules`).get().c;
const notes = db.prepare(`SELECT COUNT(*) AS c FROM notes`).get().c;
const attachments = db.prepare(`SELECT COUNT(*) AS c FROM attachments`).get().c;
res.json({ ok: true, counts: { systems, modules, notes, attachments } });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
app.get('/api/ai-lib/systems', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
res.json({ ok:true, rows: listSystems() })
})
app.post('/api/ai-lib/systems', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const body = req.body||{}
const name = String(body.name||'').trim()
const intro = String(body.intro||'').trim()
const major_category = String(body.major_category||'').trim()
if (!name || !intro) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createSystem({ name, intro, major_category })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/systems/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const raw = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const name = String(raw.name||'').trim()
const intro = String(raw.intro||'').trim()
const major_category = String(raw.major_category||'').trim()
if (!name || !intro) return res.status(400).json({ ok:false, error:'empty_fields' })
updateSystem({ id: raw.id, name, intro, major_category })
res.json({ ok:true })
})
app.delete('/api/ai-lib/systems/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteSystem(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/categories', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
res.setHeader('Cache-Control', 'no-store')
res.json({ ok:true, rows: listMajorCategories() })
})
app.post('/api/ai-lib/categories', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const name = String(req.body.name || '').trim()
if (!name) return res.status(400).json({ ok:false, error:'empty_name' })
const id = createMajorCategory(name)
res.json({ ok:true, id })
})
app.put('/api/ai-lib/categories/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const name = String(req.body.name || '').trim()
if (!name) return res.status(400).json({ ok:false, error:'empty_name' })
updateMajorCategory(req.params.id, name)
res.json({ ok:true })
})
app.delete('/api/ai-lib/categories/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteMajorCategory(req.params.id)
res.json({ ok:true })
})
app.get('/api/ai-lib/modules', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const sid = parseInt(String(req.query.system_id||'0'),10)
res.json({ ok:true, rows: listModules(sid) })
})
app.post('/api/ai-lib/modules', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const b = req.body||{}
const sid = parseInt(b.system_id,10)
const name = String(b.name||'').trim()
const description = String(b.description||'').trim()
const server_addr = String(b.server_addr||'').trim()
const portStr = String(b.server_port||'').trim()
const server_port = parseInt(portStr||'0',10)
const code_type = String(b.code_type||'').trim()
const deploy_method = String(b.deploy_method||'').trim()
const software_versions = String(b.software_versions||'').trim()
if (!sid || !name || !description || !server_addr || !portStr || !(server_port>0) || !code_type || !deploy_method || !software_versions) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createModule({ system_id: sid, name, description, server_addr, server_port, code_type, deploy_method, software_versions })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/modules/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const r = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const name = String(r.name||'').trim()
const description = String(r.description||'').trim()
const server_addr = String(r.server_addr||'').trim()
const portStr = String(r.server_port||'').trim()
const server_port = parseInt(portStr||'0',10)
const code_type = String(r.code_type||'').trim()
const deploy_method = String(r.deploy_method||'').trim()
const software_versions = String(r.software_versions||'').trim()
if (!name || !description || !server_addr || !portStr || !(server_port>0) || !code_type || !deploy_method || !software_versions) return res.status(400).json({ ok:false, error:'empty_fields' })
updateModule({ id: r.id, name, description, server_addr, server_port, code_type, deploy_method, software_versions })
res.json({ ok:true })
})
app.delete('/api/ai-lib/modules/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteModule(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/notes', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const mid = parseInt(String(req.query.module_id||'0'),10)
res.json({ ok:true, rows: listNotes(mid) })
})
app.post('/api/ai-lib/notes', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const b = req.body||{}
const mid = parseInt(b.module_id,10)
const note_type = String(b.note_type||'').trim()
const content = String(b.content||'').trim()
if (!mid || !note_type || !content) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createNote({ module_id: mid, note_type, content })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/notes/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const r = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const note_type = String(r.note_type||'').trim()
const content = String(r.content||'').trim()
if (!note_type || !content) return res.status(400).json({ ok:false, error:'empty_fields' })
updateNote({ id: r.id, note_type, content })
res.json({ ok:true })
})
app.delete('/api/ai-lib/notes/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteNote(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/attachments', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const nid = parseInt(String(req.query.note_id||'0'),10)
res.json({ ok:true, rows: listAttachments(nid) })
})
app.post('/api/ai-lib/attachments/upload', require('express').raw({ type:'application/octet-stream', limit:'200mb' }), (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const note_id = parseInt(String(req.query.note_id||'0'),10)
const filename = String(req.query.filename||'file.bin')
const mime = String(req.query.mime||'application/octet-stream')
const raw = req.body || Buffer.alloc(0)
const id = saveAttachmentChunk(note_id, filename, mime, raw)
res.json({ ok:true, id })
} catch (e) {
res.status(500).json({ ok:false, error: String(e.message||e) })
}
})
app.post('/api/ai-lib/attachments/chunk', require('express').raw({ type:'application/octet-stream', limit:'200mb' }), (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const note_id = parseInt(String(req.query.note_id||'0'),10)
const filename = String(req.query.filename||'file.bin')
const mime = String(req.query.mime||'application/octet-stream')
const idx = parseInt(String(req.query.index||'0'),10)
const total = parseInt(String(req.query.total||'1'),10)
const uploadId = String(req.query.upload_id||'') || `${Date.now()}-${Math.random().toString(36).slice(2)}`
const chunkDir = path.join(process.cwd(), 'uploads', 'ai-lib', 'chunks', uploadId)
if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir, { recursive: true })
const raw = req.body || Buffer.alloc(0)
const out = path.join(chunkDir, `${String(idx).padStart(6,'0')}.part`)
fs.writeFileSync(out, raw)
const metaPath = path.join(chunkDir, 'meta.json')
let meta = { note_id, filename, mime, total, size: 0 }
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) } catch {}
meta.note_id = note_id; meta.filename = filename; meta.mime = mime; meta.total = total; meta.size += raw.length
fs.writeFileSync(metaPath, JSON.stringify(meta))
logJSON('ai-lib.upload.chunk', { uploadId, idx, total, size: raw.length, filename }, 'ai-lib')
if (idx+1 === total) {
const parts = fs.readdirSync(chunkDir).filter(n=>/\.part$/.test(n)).sort()
const buffers = []
for (const p of parts) buffers.push(fs.readFileSync(path.join(chunkDir, p)))
const combined = Buffer.concat(buffers)
const id = saveAttachmentChunk(note_id, filename, mime, combined)
fs.rmSync(chunkDir, { recursive: true, force: true })
logJSON('ai-lib.upload.finalize', { uploadId, parts: parts.length, totalSize: combined.length, attachment_id: id }, 'ai-lib')
return res.json({ ok:true, done:true, id })
}
res.json({ ok:true, upload_id: uploadId, index: idx })
} catch (e) {
res.status(500).json({ ok:false, error:String(e.message||e) })
}
})
app.get('/api/ai-lib/attachments/:id/download', (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const id = parseInt(req.params.id,10)
const r = db.prepare(`SELECT * FROM attachments WHERE id=?`).get(id)
if (!r) return res.status(404).json({ ok:false, error:'not_found' })
const filename = dec(r.filename_enc, r.filename_iv, r.filename_tag) || 'file.bin'
const mime = dec(r.mime_enc, r.mime_iv, r.mime_tag) || 'application/octet-stream'
const full = dec(r.store_enc, r.store_iv, r.store_tag)
const raw = fs.readFileSync(full)
const iv = raw.slice(0,12)
const tag = raw.slice(12,28)
const ct = raw.slice(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(), iv)
decipher.setAuthTag(tag)
const buf = Buffer.concat([decipher.update(ct), decipher.final()])
res.setHeader('Content-Type', mime)
const ascii = filename.replace(/[^a-zA-Z0-9._-]/g,'_')
const utf8 = encodeURIComponent(filename)
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`)
res.send(buf)
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
}
module.exports = { bindRoutes, getAttachmentDataByStoredPath }
+263
View File
@@ -0,0 +1,263 @@
const express = require('express')
const Database = require('better-sqlite3');
const path = require('path');
const axios = require('axios');
const fs = require('fs');
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
let db = null;
const EXT_API_URL = `http://127.0.0.1:${process.env.PORT || 8976}/api/weekly/ext/event`;
const EXT_API_KEY = process.env.CALENDAR_REMINDER_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '';
const EXT_API_SOURCE = 'crm';
const ensureDb = () => {
if (db) return;
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
start TEXT NOT NULL,
end TEXT,
allDay INTEGER NOT NULL DEFAULT 0,
remindAt TEXT,
recurrence TEXT,
createdAt TEXT,
updatedAt TEXT,
completed INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
);`);
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name));
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;");
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;");
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`);
};
const getUUID = () => {
try { return require('crypto').randomUUID(); } catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); }
};
const syncExternalUpsert = async (ev) => {
try {
const title = (ev.title || '').trim();
const contentRaw = (ev.content || '').trim();
const start = ev.start;
const end = ev.end;
const remindAt = ev.remindAt;
let contentBuilt = contentRaw;
if (remindAt) contentBuilt = `[提醒: ${remindAt}]\n${contentBuilt}`;
if (end) contentBuilt = `[结束: ${end}]\n${contentBuilt}`;
if (ev.recurrence) contentBuilt = `[循环: ${ev.recurrence}]\n${contentBuilt}`;
contentBuilt = contentBuilt.trim();
const sDate = start ? start.slice(0, 10) : new Date().toISOString().slice(0, 10);
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: ev.id,
date: sDate,
title: title,
content: contentBuilt,
status_code: ev.completed ? 'done' : (ev.deleted ? 'deleted' : 'new'),
status_label: ev.completed ? '✅已完成' : (ev.deleted ? '🗑已删除' : '📅日程'),
deadline: remindAt || end || start
};
if (ev.deleted) payload.action = 'delete';
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync failed', err.message));
} catch (e) {
console.error('Sync error', e);
}
};
const syncExternalDelete = async (id) => {
try {
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: id,
action: 'delete',
deleted: true
};
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync delete failed', err.message));
} catch (e) {
console.error('Sync delete error', e);
}
};
const handleGet = (req, res) => {
const { since, year, month } = req.query;
let rows = [];
if (since) {
rows = db.prepare("SELECT * FROM events WHERE updatedAt >= ?").all(since);
} else if (year && month) {
const y = parseInt(year);
const m = parseInt(month);
const startStr = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString();
const endStr = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)).toISOString();
rows = db.prepare("SELECT * FROM events WHERE start >= ? AND start <= ?").all(startStr, endStr);
} else {
rows = db.prepare("SELECT * FROM events").all();
}
try {
const qInfo = since ? `since=${since}` : (year ? `year=${year},month=${month}` : 'ALL');
console.log(`[AppCalendarReminder] GetEvents ${qInfo} -> ${rows.length} rows`);
} catch(e){}
const data = rows.map(r => ({
id: r.id,
title: r.title,
content: r.content || '',
start: r.start,
end: r.end,
allDay: !!r.allDay,
remindAt: r.remindAt,
recurrence: r.recurrence,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
completed: !!r.completed,
deleted: !!r.deleted
}));
res.json({ ok: true, data });
};
const handlePost = async (req, res) => {
const ev = req.body || {};
if (ev.title && ev.start) {
try {
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start);
if (existing) {
ev.id = existing.id;
}
} catch (e) { console.error('Deduplication check failed', e); }
}
if (!ev.id) ev.id = getUUID();
ev.content = ev.content || '';
ev.createdAt = ev.createdAt || new Date().toISOString();
ev.updatedAt = ev.updatedAt || new Date().toISOString();
ev.completed = ev.completed ? 1 : 0;
ev.deleted = ev.deleted ? 1 : 0;
ev.allDay = ev.allDay ? 1 : 0;
const stmt = db.prepare(`INSERT OR REPLACE INTO events
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`);
stmt.run({
id: ev.id,
title: ev.title,
content: ev.content,
start: ev.start,
end: ev.end || null,
allDay: ev.allDay,
remindAt: ev.remindAt || null,
recurrence: ev.recurrence || null,
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
});
await syncExternalUpsert(ev);
res.json({ ok: true });
};
const handlePut = async (req, res) => {
let ev = req.body || {};
const id = req.query.id || ev.id;
if (!id) return res.status(400).json({ ok: false, error: 'missing id' });
ev.id = id;
ev.updatedAt = new Date().toISOString();
const existing = db.prepare("SELECT * FROM events WHERE id = ?").get(id);
if (!existing) return res.status(404).json({ ok: false, error: 'not found' });
const merged = { ...existing, ...ev };
merged.completed = merged.completed ? 1 : 0;
merged.deleted = merged.deleted ? 1 : 0;
merged.allDay = merged.allDay ? 1 : 0;
const stmt = db.prepare(`UPDATE events SET
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
updatedAt=@updatedAt,completed=@completed,deleted=@deleted WHERE id=@id`);
stmt.run({
id: merged.id,
title: merged.title,
content: merged.content,
start: merged.start,
end: merged.end,
allDay: merged.allDay,
remindAt: merged.remindAt,
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
});
await syncExternalUpsert(merged);
res.json({ ok: true });
};
const handleDelete = async (req, res) => {
const id = req.query.id;
if (!id) return res.status(400).json({ ok: false, error: 'missing id' });
const updatedAt = new Date().toISOString();
db.prepare("UPDATE events SET deleted=1, updatedAt=? WHERE id=?").run(updatedAt, id);
await syncExternalDelete(id);
res.json({ ok: true });
};
const bindRoutes = (app) => {
ensureDb();
app.all('/api/app_calendar/events', (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
next();
}, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body); } catch {}
}
try {
let method = req.method;
if (req.query._method) method = req.query._method.toUpperCase();
if (method === 'GET') await handleGet(req, res);
else if (method === 'POST') await handlePost(req, res);
else if (method === 'PUT') await handlePut(req, res);
else if (method === 'DELETE') await handleDelete(req, res);
else res.status(405).json({ ok: false, error: 'Method Not Allowed' });
} catch (e) {
console.error('[AppCalendarReminder] Error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
};
module.exports = { bindRoutes };
+154
View File
@@ -0,0 +1,154 @@
const Database = require('better-sqlite3')
const path = require('path')
const { bindRoutes: bindMotionReminderRoutes } = require('./kits/motion_reminder')
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'app_order_box_core.db')
const STALE_RUNNING_MS = 12 * 60 * 60 * 1000
let db = null
const KIT_META = [
{
id: 'motion_reminder',
name: '运动提醒宝',
description: '流程卡片、自动切步、语音播报、大字倒计时。',
},
]
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(CORE_DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS core_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_kit TEXT,
running_kit TEXT,
running_session_id TEXT,
updated_at TEXT NOT NULL
);`)
db.prepare(`
INSERT OR IGNORE INTO core_state (id, active_kit, running_kit, running_session_id, updated_at)
VALUES (1, NULL, NULL, NULL, ?)
`).run(nowIso())
return db
}
const getStateRow = () => {
const row = getDb().prepare(`SELECT * FROM core_state WHERE id = 1 LIMIT 1`).get()
if (!row) {
getDb().prepare(`
INSERT INTO core_state (id, active_kit, running_kit, running_session_id, updated_at)
VALUES (1, NULL, NULL, NULL, ?)
`).run(nowIso())
return getStateRow()
}
if (row.running_session_id && row.updated_at) {
const updatedAt = new Date(row.updated_at).getTime()
if (Number.isFinite(updatedAt) && Date.now() - updatedAt > STALE_RUNNING_MS) {
resetRunningState()
return getStateRow()
}
}
return row
}
const setActiveKit = kitId => {
getDb().prepare(`UPDATE core_state SET active_kit = ?, updated_at = ? WHERE id = 1`).run(kitId || null, nowIso())
}
const reserveRunningSession = ({ kitId, sessionId }) => {
const row = getStateRow()
if (row.running_session_id) throw new Error('running session exists')
getDb().prepare(`
UPDATE core_state
SET running_kit = ?, running_session_id = ?, updated_at = ?
WHERE id = 1
`).run(kitId, sessionId, nowIso())
}
const touchRunningSession = sessionId => {
const row = getStateRow()
if (String(row.running_session_id || '') !== String(sessionId || '')) return
getDb().prepare(`UPDATE core_state SET updated_at = ? WHERE id = 1`).run(nowIso())
}
const releaseRunningSession = sessionId => {
const row = getStateRow()
if (sessionId && String(row.running_session_id || '') !== String(sessionId)) return
resetRunningState()
}
const resetRunningState = () => {
getDb().prepare(`
UPDATE core_state
SET running_kit = NULL,
running_session_id = NULL,
updated_at = ?
WHERE id = 1
`).run(nowIso())
}
const serializeState = () => {
const row = getStateRow()
return {
ok: true,
activeKit: row.active_kit || null,
runningSession: row.running_session_id
? {
kitId: row.running_kit,
sessionId: row.running_session_id,
updatedAt: row.updated_at,
}
: null,
}
}
const bindRoutes = app => {
getDb()
app.get('/api/app_order_box/meta', (req, res) => {
res.json({
ok: true,
appName: 'OrderBox',
appNameZh: '框子',
kits: KIT_META,
})
})
app.get('/api/app_order_box/state', (req, res) => {
res.json(serializeState())
})
app.put('/api/app_order_box/state/active-kit', (req, res) => {
try {
const raw = String(req.body && req.body.kitId || '').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 })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/app_order_box/state/reset-running', (req, res) => {
try {
resetRunningState()
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
bindMotionReminderRoutes(app, {
reserveRunningSession,
touchRunningSession,
releaseRunningSession,
})
}
module.exports = { bindRoutes }
@@ -0,0 +1,139 @@
const crypto = require('crypto')
const store = require('./store')
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
}
const sanitizeCard = raw => {
const steps = Array.isArray(raw.steps) ? raw.steps : []
return {
id: String(raw.id || '').trim() || createId(),
title: String(raw.title || '').trim(),
steps: steps.map((step, index) => ({
id: String(step && step.id || '').trim() || createId(),
type: String(step && step.type || 'action') === 'rest' ? 'rest' : 'action',
name: String(step && step.name || '').trim() || `步骤${index + 1}`,
durationSec: Math.max(1, Number(step && step.durationSec || 1)),
voiceText: String(step && step.voiceText || '').trim(),
})),
loopMode: ['once', 'repeat_count', 'repeat_forever'].includes(String(raw.loopMode || ''))
? String(raw.loopMode)
: 'once',
loopCount: String(raw.loopMode || '') === 'repeat_count'
? Math.max(1, Number(raw.loopCount || 1))
: null,
}
}
const bindRoutes = (app, helpers) => {
app.get('/api/app_order_box/kits/motion_reminder/cards', (req, res) => {
try {
res.json({ ok: true, data: store.listCards() })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/app_order_box/kits/motion_reminder/cards', (req, res) => {
try {
const card = sanitizeCard(req.body || {})
if (!card.title) return res.status(400).json({ ok: false, error: 'missing title' })
if (!card.steps.length) return res.status(400).json({ ok: false, error: 'missing steps' })
const saved = store.insertCard(card)
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/app_order_box/kits/motion_reminder/cards', (req, res) => {
try {
const card = sanitizeCard(req.body || {})
if (!card.id) return res.status(400).json({ ok: false, error: 'missing id' })
if (!card.title) return res.status(400).json({ ok: false, error: 'missing title' })
if (!card.steps.length) return res.status(400).json({ ok: false, error: 'missing steps' })
const existing = store.getCardById(card.id)
if (!existing) return res.status(404).json({ ok: false, error: 'card not found' })
const saved = store.updateCard(card)
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/app_order_box/kits/motion_reminder/cards', (req, res) => {
try {
const id = String(req.query.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.message || e) })
}
})
app.get('/api/app_order_box/kits/motion_reminder/history', (req, res) => {
try {
res.json({ ok: true, data: store.listHistory() })
} catch (e) {
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 : {}
const sourceCardId = String(raw.id || '').trim()
const existingCard = sourceCardId ? store.getCardById(sourceCardId) : null
const card = existingCard || sanitizeCard(raw)
if (!card.title) return res.status(400).json({ ok: false, error: 'missing card title' })
if (!Array.isArray(card.steps) || !card.steps.length) {
return res.status(400).json({ ok: false, error: 'missing card steps' })
}
const sessionId = createId()
helpers.reserveRunningSession({ kitId: 'motion_reminder', sessionId })
try {
const session = store.createSession({
id: sessionId,
cardId: card.id,
cardSnapshot: card,
})
return res.json({ ok: true, data: session })
} catch (e) {
helpers.releaseRunningSession(sessionId)
throw e
}
} catch (e) {
const status = String(e.message || '').includes('running session exists') ? 409 : 500
return res.status(status).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/app_order_box/kits/motion_reminder/sessions/sync', (req, res) => {
try {
const id = String(req.body && req.body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const saved = store.syncSession(req.body || {})
helpers.touchRunningSession(id)
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/app_order_box/kits/motion_reminder/sessions/finish', (req, res) => {
try {
const id = String(req.body && req.body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const finished = store.finishSession(req.body || {})
helpers.releaseRunningSession(id)
return res.json({ ok: true, data: finished })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
@@ -0,0 +1,233 @@
const Database = require('better-sqlite3')
const path = require('path')
const DB_PATH = path.join(process.cwd(), 'data', 'app_order_box_motion_reminder.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 cards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
steps_json TEXT NOT NULL,
loop_mode TEXT NOT NULL,
loop_count INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
card_snapshot_json TEXT NOT NULL,
status TEXT NOT NULL,
current_loop INTEGER NOT NULL DEFAULT 1,
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,
updated_at TEXT NOT NULL
);`)
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
}
const safeParseJson = (raw, fallback) => {
try {
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
const mapCard = row => ({
id: row.id,
title: row.title,
steps: safeParseJson(row.steps_json, []),
loopMode: row.loop_mode,
loopCount: row.loop_count === null || row.loop_count === undefined ? null : Number(row.loop_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
})
const mapSession = row => ({
id: row.id,
kitId: 'motion_reminder',
cardId: row.card_id,
cardSnapshot: safeParseJson(row.card_snapshot_json, null),
status: row.status,
startedAt: row.started_at,
endedAt: row.ended_at || null,
currentLoop: Number(row.current_loop || 1),
completedLoops: Number(row.completed_loops || 0),
currentStepIndex: Number(row.current_step_index || 0),
manualStopped: !!row.manual_stopped,
updatedAt: row.updated_at,
})
const listCards = () => {
const rows = getDb().prepare(`
SELECT * FROM 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 cards WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
return row ? mapCard(row) : null
}
const insertCard = card => {
const createdAt = nowIso()
const payload = {
id: card.id,
title: String(card.title || '').trim(),
steps_json: JSON.stringify(card.steps || []),
loop_mode: String(card.loopMode || 'once'),
loop_count: card.loopCount === null || card.loopCount === undefined ? null : Number(card.loopCount),
created_at: createdAt,
updated_at: createdAt,
}
getDb().prepare(`
INSERT INTO cards (id, title, steps_json, loop_mode, loop_count, created_at, updated_at, deleted)
VALUES (@id, @title, @steps_json, @loop_mode, @loop_count, @created_at, @updated_at, 0)
`).run(payload)
return getCardById(payload.id)
}
const updateCard = card => {
const payload = {
id: card.id,
title: String(card.title || '').trim(),
steps_json: JSON.stringify(card.steps || []),
loop_mode: String(card.loopMode || 'once'),
loop_count: card.loopCount === null || card.loopCount === undefined ? null : Number(card.loopCount),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE cards
SET title = @title,
steps_json = @steps_json,
loop_mode = @loop_mode,
loop_count = @loop_count,
updated_at = @updated_at
WHERE id = @id AND deleted = 0
`).run(payload)
return getCardById(payload.id)
}
const deleteCard = id => {
getDb().prepare(`UPDATE cards SET deleted = 1, updated_at = ? WHERE id = ?`).run(nowIso(), id)
}
const createSession = ({ id, cardId, cardSnapshot }) => {
const startedAt = nowIso()
const payload = {
id,
card_id: cardId,
card_snapshot_json: JSON.stringify(cardSnapshot || {}),
status: 'running',
current_loop: 1,
completed_loops: 0,
current_step_index: 0,
started_at: startedAt,
ended_at: null,
manual_stopped: 0,
updated_at: startedAt,
}
getDb().prepare(`
INSERT INTO sessions (
id, card_id, card_snapshot_json, status, current_loop, completed_loops,
current_step_index, started_at, ended_at, manual_stopped, updated_at
) VALUES (
@id, @card_id, @card_snapshot_json, @status, @current_loop, @completed_loops,
@current_step_index, @started_at, @ended_at, @manual_stopped, @updated_at
)
`).run(payload)
return getSessionById(id)
}
const getSessionById = id => {
const row = getDb().prepare(`SELECT * FROM sessions WHERE id = ? LIMIT 1`).get(id)
return row ? mapSession(row) : null
}
const syncSession = session => {
const payload = {
id: session.id,
status: String(session.status || 'running'),
current_loop: Number(session.currentLoop || 1),
completed_loops: Number(session.completedLoops || 0),
current_step_index: Number(session.currentStepIndex || 0),
manual_stopped: session.manualStopped ? 1 : 0,
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE sessions
SET status = @status,
current_loop = @current_loop,
completed_loops = @completed_loops,
current_step_index = @current_step_index,
manual_stopped = @manual_stopped,
updated_at = @updated_at
WHERE id = @id
`).run(payload)
return getSessionById(payload.id)
}
const finishSession = session => {
const payload = {
id: session.id,
status: String(session.status || 'completed'),
current_loop: Number(session.currentLoop || 1),
completed_loops: Number(session.completedLoops || 0),
current_step_index: Number(session.currentStepIndex || 0),
manual_stopped: session.manualStopped ? 1 : 0,
ended_at: nowIso(),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE sessions
SET status = @status,
current_loop = @current_loop,
completed_loops = @completed_loops,
current_step_index = @current_step_index,
manual_stopped = @manual_stopped,
ended_at = @ended_at,
updated_at = @updated_at
WHERE id = @id
`).run(payload)
return getSessionById(payload.id)
}
const listHistory = () => {
const rows = getDb().prepare(`
SELECT * FROM sessions
WHERE ended_at IS NOT NULL
ORDER BY datetime(started_at) DESC
LIMIT 200
`).all()
return rows.map(mapSession)
}
module.exports = {
listCards,
getCardById,
insertCard,
updateCard,
deleteCard,
createSession,
getSessionById,
syncSession,
finishSession,
listHistory,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const app = express()
app.use(express.json({ type: ['application/json','text/json'], limit: '2mb' }))
app.use(express.text({ type: ['application/x-www-form-urlencoded','text/plain'], limit: '2mb' }))
app.use((req,res,next)=>{ res.setHeader('Access-Control-Allow-Origin','*'); res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers','Content-Type'); if (req.method==='OPTIONS'){ res.status(200).send(''); } else next() })
const dbPath = path.join(process.cwd(), 'CalendarReminder_ServerApi', 'App_Data', 'calendar.db')
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT,
allDay INTEGER NOT NULL DEFAULT 0,
remindAt TEXT,
recurrence TEXT,
createdAt TEXT,
updatedAt TEXT,
deleted INTEGER NOT NULL DEFAULT 0
)`)
const rowToObj = (r)=>({ id:r.id, title:r.title, start:r.start, end:r.end, allDay: !!r.allDay, remindAt:r.remindAt, recurrence:r.recurrence, createdAt:r.createdAt, updatedAt:r.updatedAt, deleted: !!r.deleted })
app.get('/Api/Events.ashx', (req,res)=>{
try {
const since = req.query.since
const year = req.query.year
const month = req.query.month
let rows
if (since) rows = db.prepare('SELECT * FROM events WHERE updatedAt >= ?').all(since)
else if (year && month) {
const y = parseInt(year,10), m = parseInt(month,10)
const s = new Date(Date.UTC(y, m-1, 1, 0,0,0)).toISOString()
const e = new Date(Date.UTC(y, m-1, new Date(y, m, 0).getUTCDate(), 23,59,59)).toISOString()
rows = db.prepare('SELECT * FROM events WHERE start >= ? AND start <= ?').all(s, e)
} else rows = db.prepare('SELECT * FROM events').all()
res.json({ ok:true, data: rows.map(rowToObj) })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.post('/Api/Events.ashx', (req,res)=>{
try {
const ev = typeof req.body==='string' ? JSON.parse(req.body||'{}') : (req.body||{})
if (!ev || !ev.id) return res.status(400).json({ ok:false, error:'invalid payload' })
const stmt = db.prepare(`INSERT OR REPLACE INTO events (id,title,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,deleted) VALUES (?,?,?,?,?,?,?,?,?,?)`)
stmt.run(String(ev.id), String(ev.title||''), String(ev.start||''), String(ev.end||''), ev.allDay?1:0, String(ev.remindAt||''), String(ev.recurrence||''), String(ev.createdAt||new Date().toISOString()), String(ev.updatedAt||new Date().toISOString()), ev.deleted?1:0)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.put('/Api/Events.ashx', (req,res)=>{
try {
const ev = typeof req.body==='string' ? JSON.parse(req.body||'{}') : (req.body||{})
const id = String(req.query.id||ev.id||'')
if (!id) return res.status(400).json({ ok:false, error:'missing id' })
const stmt = db.prepare(`UPDATE events SET title=?, start=?, end=?, allDay=?, remindAt=?, recurrence=?, updatedAt=?, deleted=? WHERE id=?`)
stmt.run(String(ev.title||''), String(ev.start||''), String(ev.end||''), ev.allDay?1:0, String(ev.remindAt||''), String(ev.recurrence||''), String(ev.updatedAt||new Date().toISOString()), ev.deleted?1:0, id)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.delete('/Api/Events.ashx', (req,res)=>{
try {
const id = String(req.query.id||'')
if (!id) return res.status(400).json({ ok:false, error:'missing id' })
db.prepare('UPDATE events SET deleted=1, updatedAt=? WHERE id=?').run(new Date().toISOString(), id)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
const port = process.env.CALENDAR_PORT || 8976
app.listen(port, ()=>{ console.log(`CalendarReminder local API on http://localhost:${port}/Api/Events.ashx`) })
+437
View File
@@ -0,0 +1,437 @@
const express = require('express')
const Database = require('better-sqlite3')
const path = require('path')
const axios = require('axios')
const fs = require('fs')
const readCookieCfg = () => {
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
let name = 'calendar_reminder_gate'
let maxAge = 90 * 24 * 3600 * 1000
let enable = true
try {
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (cfg.cookieName) name = String(cfg.cookieName)
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
if (cfg.enable_auth === false) enable = false
}
} catch {}
return { name, maxAge, enable }
}
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')
let db = null
// Config
const EXT_API_URL = `http://127.0.0.1:${process.env.PORT || 8976}/api/weekly/ext/event`
const EXT_API_KEY = process.env.CALENDAR_REMINDER_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || ''
const EXT_API_SOURCE = 'crm' // Kept from C# default
const readPublicApiKey = () => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const k = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
if (k) return k
}
} catch {}
return '099239923'
}
const PUBLIC_API_KEY = readPublicApiKey()
const parseCookie = (str) => {
const list = {}
if (!str) return list
str.split(';').forEach((cookie) => {
const parts = cookie.split('=')
list[parts.shift().trim()] = decodeURI(parts.join('='))
})
return list
}
const checkAuth = (req, res, next) => {
const cookies = parseCookie(req.headers.cookie || '')
const { name, enable } = readCookieCfg()
if (enable === false) return next()
if (cookies[name] === '1') return next()
try {
const ref = String(req.headers['referer'] || req.headers['referrer'] || '')
if (ref.includes('/tools/calendar_voice/')) return next()
} catch {}
const hasOrigin = !!req.headers['origin']
const hasSecFetch = !!req.headers['sec-fetch-mode']
if (!hasOrigin && !hasSecFetch) return next()
return res.status(401).json({ error: 'unauthorized', ok: false })
}
const ensureDb = () => {
if (db) return
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
// Create table if not exists (migrated from C#)
db.exec(`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
start TEXT NOT NULL,
end TEXT,
allDay INTEGER NOT NULL DEFAULT 0,
remindAt TEXT,
recurrence TEXT,
createdAt TEXT,
updatedAt TEXT,
completed INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
);`)
// Ensure columns
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name))
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;")
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;")
// Create indices to speed up queries
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`)
}
// Helpers
const getUUID = () => {
try { return require('crypto').randomUUID() } catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) }
}
const syncExternalUpsert = async (ev) => {
try {
const title = (ev.title || '').trim()
const contentRaw = (ev.content || '').trim()
const start = ev.start
const end = ev.end
const remindAt = ev.remindAt
// Logic from C# BuildExternalContent
let contentBuilt = contentRaw
if (remindAt) contentBuilt = `[提醒: ${remindAt}]\n${contentBuilt}`
if (end) contentBuilt = `[结束: ${end}]\n${contentBuilt}`
if (ev.recurrence) contentBuilt = `[循环: ${ev.recurrence}]\n${contentBuilt}`
contentBuilt = contentBuilt.trim()
// Date logic
const sDate = start ? start.slice(0, 10) : new Date().toISOString().slice(0, 10)
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: ev.id,
date: sDate,
title: title,
content: contentBuilt,
status_code: ev.completed ? 'done' : (ev.deleted ? 'deleted' : 'new'),
status_label: ev.completed ? '✅已完成' : (ev.deleted ? '🗑已删除' : '📅日程'),
deadline: remindAt || end || start
}
if (ev.deleted) payload.action = 'delete'
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync failed', err.message))
} catch (e) {
console.error('Sync error', e)
}
}
const syncExternalDelete = async (id) => {
try {
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: id,
action: 'delete',
deleted: true
}
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync delete failed', err.message))
} catch (e) {
console.error('Sync delete error', e)
}
}
const handleGet = (req, res) => {
const { since, year, month } = req.query
let rows = []
if (since) {
rows = db.prepare("SELECT * FROM events WHERE updatedAt >= ?").all(since)
} else if (year && month) {
const y = parseInt(year)
const m = parseInt(month)
// Construct simplified date strings for comparison (sqlite stores text ISO)
// C# used DateTime comparison. Here we rely on ISO string comparison which works for YYYY-MM-DD
// But events.start might be YYYY-MM-DDTHH:mm:ss.
// Construct range:
const startStr = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString()
// End of month
const endStr = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)).toISOString()
rows = db.prepare("SELECT * FROM events WHERE start >= ? AND start <= ?").all(startStr, endStr)
} else {
rows = db.prepare("SELECT * FROM events").all()
}
try {
const qInfo = since ? `since=${since}` : (year ? `year=${year},month=${month}` : 'ALL');
console.log(`[CalendarReminder] GetEvents ${qInfo} -> ${rows.length} rows`);
} catch(e){}
const data = rows.map(r => ({
id: r.id,
title: r.title,
content: r.content || '',
start: r.start,
end: r.end,
allDay: !!r.allDay,
remindAt: r.remindAt,
recurrence: r.recurrence,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
completed: !!r.completed,
deleted: !!r.deleted
}))
res.json({ ok: true, data })
}
const handlePost = async (req, res) => {
const ev = req.body || {}
// Deduplication: if title and start match an existing active event, update it instead of creating new
if (ev.title && ev.start) {
try {
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start)
if (existing) {
ev.id = existing.id
}
} catch (e) { console.error('Deduplication check failed', e) }
}
if (!ev.id) ev.id = getUUID()
// Defaults
ev.content = ev.content || ''
ev.createdAt = ev.createdAt || new Date().toISOString()
ev.updatedAt = ev.updatedAt || new Date().toISOString()
ev.completed = ev.completed ? 1 : 0
ev.deleted = ev.deleted ? 1 : 0
ev.allDay = ev.allDay ? 1 : 0
const stmt = db.prepare(`INSERT OR REPLACE INTO events
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`)
stmt.run({
id: ev.id,
title: ev.title,
content: ev.content,
start: ev.start,
end: ev.end || null,
allDay: ev.allDay,
remindAt: ev.remindAt || null,
recurrence: ev.recurrence || null,
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
})
await syncExternalUpsert(ev)
res.json({ ok: true })
}
const handlePut = async (req, res) => {
// C# logic: UPDATE specific fields
// Node logic: Similar
let ev = req.body || {}
const id = req.query.id || ev.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
ev.id = id
ev.updatedAt = new Date().toISOString()
// Need to read existing event to merge?
// C# HandlePut updates ALL fields provided in the query/body.
// It runs an UPDATE statement setting all fields.
// Since we might not have all fields in body, we should probably merge or expect full object?
// C# HandlePut SQL: UPDATE events SET title=@title, ... WHERE id=@id.
// It expects the body to contain the fields.
// Frontend sends full object usually.
// Let's assume body contains fields to update.
// However, simple SQL UPDATE requires all params.
// We can use COALESCE or read-modify-write.
const existing = db.prepare("SELECT * FROM events WHERE id = ?").get(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const merged = { ...existing, ...ev }
merged.completed = merged.completed ? 1 : 0
merged.deleted = merged.deleted ? 1 : 0
merged.allDay = merged.allDay ? 1 : 0
const stmt = db.prepare(`UPDATE events SET
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
updatedAt=@updatedAt,completed=@completed,deleted=@deleted WHERE id=@id`)
stmt.run({
id: merged.id,
title: merged.title,
content: merged.content,
start: merged.start,
end: merged.end,
allDay: merged.allDay,
remindAt: merged.remindAt,
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
})
await syncExternalUpsert(merged)
res.json({ ok: true })
}
const handleDelete = async (req, res) => {
const id = req.query.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const updatedAt = new Date().toISOString()
db.prepare("UPDATE events SET deleted=1, updatedAt=? WHERE id=?").run(updatedAt, id)
await syncExternalDelete(id)
res.json({ ok: true })
}
const bindRoutes = (app) => {
ensureDb()
app.get('/api/calendar/sys-config', (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
res.json({ ok: true, config: cfg })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/calendar/auth', checkAuth, (req, res) => {
res.json({ ok: true, enabled: true })
})
// Public (no-auth) endpoints
app.get('/api/calendar/public/auth', (req, res) => {
res.json({ ok: true, enabled: true, public: true })
})
app.all('/api/calendar/public/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
const key = String(req.query.key || '')
if (!key || key !== PUBLIC_API_KEY) {
return res.status(403).json({ ok: false, error: 'forbidden' })
}
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// 专门为外部程序(如WinForm)提供的完全开放接口
app.all('/api/calendar/external/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
// 设置CORS头,允许所有来源访问
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// Legacy route compatibility for CalendarReminder_WebApp
app.all('/Api/Events.ashx', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') return res.status(200).end()
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// Events
app.all('/api/calendar/events', checkAuth, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
// Note: C# supported text/plain body to avoid CORS preflight.
// We handle text body if json parse fails or if type is text.
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
}
module.exports = { bindRoutes }
+180
View File
@@ -0,0 +1,180 @@
const express = require('express')
const axios = require('axios')
const multer = require('multer')
const Core = require('@alicloud/pop-core')
// 阿里云配置(从环境变量读取,见 ~/Toolbox_local_creds.env.local)
const ALIYUN_CONFIG = {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || 'LTAI5tNBSh1JXpFGXVRubNhK',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || 'G0X8aOIA4bgUkSl1fgdgZv1GKFBJAd',
appKey: process.env.ALIYUN_APPKEY || '33kbhbwH6vYUdDIG',
endpoint: 'https://nls-meta.cn-shanghai.aliyuncs.com',
apiVersion: '2019-02-28'
}
// 缓存 Token
let cachedToken = null
let tokenExpire = 0
const getToken = async () => {
const now = Date.now() / 1000
if (cachedToken && now < tokenExpire) {
return cachedToken
}
try {
const client = new Core({
accessKeyId: ALIYUN_CONFIG.accessKeyId,
accessKeySecret: ALIYUN_CONFIG.accessKeySecret,
endpoint: ALIYUN_CONFIG.endpoint,
apiVersion: ALIYUN_CONFIG.apiVersion
})
const params = {}
const requestOption = {
method: 'POST',
formatParams: false
}
const result = await client.request('CreateToken', params, requestOption)
if (result.Token && result.Token.Id) {
cachedToken = result.Token.Id
tokenExpire = result.Token.ExpireTime
console.log('[CalendarVoice] Got new NLS token, expires:', tokenExpire)
return cachedToken
}
} catch (e) {
console.error('[CalendarVoice] Get token failed:', e)
}
return null
}
// 内存存储上传文件
const upload = multer({ storage: multer.memoryStorage() })
const bindRoutes = (app) => {
// 语音识别接口
app.post('/api/calendar_voice/recognize', upload.single('audio'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ ok: false, error: 'No audio file' })
const token = await getToken()
if (!token) return res.status(500).json({ ok: false, error: 'Failed to get NLS token' })
// 调用阿里云一句话识别 RESTful API
// 文档: https://help.aliyun.com/document_detail/84435.html
const url = `http://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr?appkey=${ALIYUN_CONFIG.appKey}&format=pcm&sample_rate=16000&enable_punctuation_prediction=true&enable_inverse_text_normalization=true`
const response = await axios.post(url, req.file.buffer, {
headers: {
'X-NLS-Token': token,
'Content-Type': 'application/octet-stream'
},
maxBodyLength: Infinity,
maxContentLength: Infinity
})
if (response.status === 200 && response.data.result) {
console.log('[CalendarVoice] Recognized:', response.data.result)
res.json({ ok: true, text: response.data.result })
} else {
console.error('[CalendarVoice] API Error:', response.data)
res.status(500).json({ ok: false, error: response.data.message || 'Recognition failed' })
}
} catch (e) {
console.error('[CalendarVoice] Error:', e.message)
res.status(500).json({ ok: false, error: e.message })
}
})
// AI 总结接口 (DeepSeek via DashScope)
app.post('/api/calendar_voice/summarize', express.json(), async (req, res) => {
const { text } = req.body
if (!text) return res.status(400).json({ ok: false, error: 'No text provided' })
const apiKey = process.env.DASHSCOPE_API_KEY || ''
// 使用 OpenAI 兼容接口调用 DeepSeek-V3
const url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
const systemPrompt = `# Role
你是一名资深的时间管理专家和高效秘书,擅长从冗杂的口语录音中快速提炼核心日程主题。
# Goal
分析用户输入的【语音转写文本】,将其概括为一个简短、精准的日程标题(Event Title)。
# Constraints
1. **字数限制**:标题长度严格控制在 **5-15个字** 之间。
2. **去口语化**:坚决去除“那个”、“就是”、“我想说”、“嗯啊”等无意义的口语词汇。
3. **语法结构**:优先使用“**动词 + 名词**”的结构(例如:提交季度报表、参加家长会)。
4. **去时间词**:不需要在标题中包含具体的日期和时间(如“明天下午”、“三点钟”),重点描述“做什么”。
5. **输出纯净**:**只输出标题文字本身**,严禁包含任何标点符号、解释、前缀(如“标题:”)或引号。
# Scenarios & Examples
**场景1:工作会议**
输入:哎你好,就是明天下午三点钟,我要去那个科技园参加一个关于人工智能的研讨会,记得提醒我带电脑。
输出:参加人工智能研讨会
**场景2:生活琐事**
输入:下班后记得去超市买点鸡蛋和牛奶,还有那个洗洁精也没了,顺便买一下。
输出:超市购买生活用品
**场景3:个人安排**
输入:这周末打算把家里那个储藏室整理一下,太乱了。
输出:整理家中储藏室`
try {
const response = await axios.post(url, {
model: 'qwen-plus-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1 // 低温度以保证稳定输出
}, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
if (response.data && response.data.choices && response.data.choices.length > 0) {
const title = response.data.choices[0].message.content.trim()
console.log('[CalendarVoice] Summarized:', title)
res.json({ ok: true, title })
} else {
console.error('[CalendarVoice] AI Response Error:', response.data)
res.status(500).json({ ok: false, error: 'AI processing failed' })
}
} catch (e) {
console.error('[CalendarVoice] AI Error:', e.response ? e.response.data : e.message)
// 降级重试
if (e.response && (e.response.status === 404 || e.response.status === 400)) {
console.log('Retrying with qwen-plus-latest...')
try {
const retryRes = await axios.post(url, {
model: 'qwen-plus-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
]
}, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
const title = retryRes.data.choices[0].message.content.trim()
res.json({ ok: true, title })
return
} catch (err2) {
console.error('Retry failed:', err2.message)
}
}
res.status(500).json({ ok: false, error: 'AI service unavailable' })
}
})
}
module.exports = { bindRoutes }
+318
View File
@@ -0,0 +1,318 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const axios = require('axios')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'ccb_private_funds.db')
const db = new Database(dbPath)
try { db.pragma('journal_mode = WAL') } catch {}
try { db.pragma('synchronous = NORMAL') } catch {}
const uploadsDir = path.join(process.cwd(), 'uploads', 'ccb_private_funds', 'attachments')
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true })
const cfgPath = path.join(process.cwd(), 'config', 'ccb_private_funds.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) {
const defaultPort = process.env.PORT || 5050
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex'), display: { currency: 'CNY' }, weekly_api_base: defaultBase, weekly_key: '', weekly_sync_enabled: true }, null, 2))
}
const getConfig = () => {
if (_cachedCfg) return _cachedCfg
_cachedCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cachedCfg
}
const setConfig = cfg => {
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
_cachedCfg = null
_cachedKey = null
}
let _cachedCfg = null
let _cachedKey = null
const deriveKey = () => {
if (_cachedKey) return _cachedKey
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.CCB_PRIVATE_FUNDS_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
_cachedKey = crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
return _cachedKey
}
const enc = plain => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain || ''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
ending_cents INTEGER NOT NULL,
bank_status_enc BLOB,
bank_status_iv BLOB,
bank_status_tag BLOB,
remark_enc BLOB,
remark_iv BLOB,
remark_tag BLOB,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flow_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flow_id INTEGER NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
mime TEXT DEFAULT 'application/octet-stream',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(flow_id) REFERENCES fund_flows(id) ON DELETE CASCADE
);`
)
const insertFlow = db.prepare(
`INSERT INTO fund_flows (date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag)
VALUES (@date, @type, @amount_cents, 0, @bank_status_enc, @bank_status_iv, @bank_status_tag, @remark_enc, @remark_iv, @remark_tag)`
)
const updateFlow = db.prepare(
`UPDATE fund_flows SET date=@date, type=@type, amount_cents=@amount_cents, bank_status_enc=@bank_status_enc, bank_status_iv=@bank_status_iv, bank_status_tag=@bank_status_tag, remark_enc=@remark_enc, remark_iv=@remark_iv, remark_tag=@remark_tag, updated_at=datetime('now') WHERE id=@id`
)
const deleteFlowStmt = db.prepare(`DELETE FROM fund_flows WHERE id = ?`)
const listFlowsStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows ORDER BY date ASC, id ASC`)
const listRangeStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date>=? AND date<=? ORDER BY date ASC, id ASC`)
const listByDateStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date=? ORDER BY id ASC`)
const sumByDateStmt = db.prepare(`SELECT SUM(amount_cents) AS s FROM fund_flows WHERE date=?`)
const updateEndingStmt = db.prepare(`UPDATE fund_flows SET ending_cents=? WHERE id=?`)
const countAttachmentsByFlowStmt = db.prepare(`SELECT COUNT(1) AS c FROM fund_flow_attachments WHERE flow_id=?`)
const listAttachmentsByFlowStmt = db.prepare(`SELECT id, flow_id, filename, path, size_bytes, mime, created_at FROM fund_flow_attachments WHERE flow_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, flow_id, filename, path, size_bytes, mime, created_at FROM fund_flow_attachments WHERE id=?`)
const insertAttachmentStmt = db.prepare(`INSERT INTO fund_flow_attachments (flow_id, filename, path, size_bytes, mime) VALUES (@flow_id, @filename, @path, @size_bytes, @mime)`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM fund_flow_attachments WHERE id=?`)
const deleteAttachmentsByFlowStmt = db.prepare(`DELETE FROM fund_flow_attachments WHERE flow_id=?`)
const recalcEndingFrom = db.transaction(startDate => {
const all = listFlowsStmt.all()
let balance = 0
for (const r of all) {
if (startDate) {
if (r.date < startDate) { balance += (r.amount_cents || 0); continue }
}
balance += (r.amount_cents || 0)
updateEndingStmt.run(balance, r.id)
}
})
const listFlows = (start, end, type, kw) => {
const rows = (start && end) ? listRangeStmt.all(start, end) : listFlowsStmt.all()
const mapRow = r => {
const bank = r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : ''
const remark = r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
const c = countAttachmentsByFlowStmt.get(r.id)
return { id: r.id, date: r.date, type: r.type, amount_cents: r.amount_cents, ending_cents: r.ending_cents, bank_status: bank, remark, attachments_count: Number((c && c.c) || 0) }
}
let filtered = rows.map(mapRow)
if (type && type !== 'all') filtered = filtered.filter(r => r.type === type)
if (kw) { const reg = new RegExp(String(kw).trim(), 'i'); filtered = filtered.filter(r => reg.test(r.bank_status || '') || reg.test(r.remark || '')) }
return filtered
}
const formatCNY = cents => {
try { return (Number(cents || 0) / 100).toFixed(2) } catch { return '0.00' }
}
const brief = s => { try { return String(s || '').replace(/\s+/g, ' ').slice(0, 500) } catch { return '' } }
const getWeeklyCfg = () => {
const cfg = getConfig()
const port = process.env.PORT || 5050
const base = cfg.weekly_api_base || `http://localhost:${port}/api/weekly`
const key = String(cfg.weekly_key || process.env.SYSTEM_AUTH_PASS_93220 || '')
const enabled = cfg.weekly_sync_enabled !== false
return { base, key, enabled }
}
const pushWeeklyDaily = async date => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
const total = (sumByDateStmt.get(date).s || 0)
const title = `建行私募理财 ${date}(净变动:${formatCNY(total)})`
const items = rows.map((r, idx) => ({
idx: idx + 1,
type: r.type,
amount: formatCNY(r.amount_cents),
bank: r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : '',
remark: r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
}))
const trs = items.map(it => `<tr><td>${it.idx}</td><td>${it.type === 'in' ? '汇入' : '汇出'}</td><td>${it.amount}</td><td>${(it.bank || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td><td>${(it.remark || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td></tr>`).join('')
const content = `
<div>
<h4>当日净变动</h4>
<p>人民币:${formatCNY(total)}</p>
<h4>明细</h4>
<table border="1" cellspacing="0" cellpadding="4">
<thead><tr><th>序号</th><th>类型</th><th>金额(元)</th><th>银行方/状态</th><th>备注</th></tr></thead>
<tbody>${trs}</tbody>
</table>
</div>
`.trim()
const payload = { key: api.key, source: 'ccb_private_funds', external_id: `ccb-private-funds-daily-${date}`, title, content, date, status_code: 'new', status_label: '📅本周新增' }
const url = `${api.base}/ext/event`
logJSON('ccb_private_funds.weekly.push.try', { date, url, base: api.base, rows: rows.length, total_cents: total, title_len: title.length, content_len: content.length }, 'ccb_private_funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('ccb_private_funds.weekly.push.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'ccb_private_funds')
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('ccb_private_funds.weekly.push.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message || e), data: typeof data === 'string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'ccb_private_funds')
}
}
const deleteOrUpdateWeeklyDaily = async date => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
if (!rows || rows.length === 0) {
const url = `${api.base}/ext/event`
const payload = { key: api.key, source: 'ccb_private_funds', external_id: `ccb-private-funds-daily-${date}`, date, deleted: true }
logJSON('ccb_private_funds.weekly.delete.try', { date, url, base: api.base }, 'ccb_private_funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('ccb_private_funds.weekly.delete.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'ccb_private_funds')
} else {
await pushWeeklyDaily(date)
}
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('ccb_private_funds.weekly.delete.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message || e), data: typeof data === 'string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'ccb_private_funds')
}
}
const createFlow = payload => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status || '')
const rm = enc(remark || '')
const info = insertFlow.run({ date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('ccb_private_funds.create', { id: info.lastInsertRowid, date, type, amount_cents }, 'ccb_private_funds')
Promise.resolve().then(() => pushWeeklyDaily(date)).catch(() => {})
return Number(info.lastInsertRowid || 0)
}
const updateFlowById = (id, payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status || '')
const rm = enc(remark || '')
updateFlow.run({ id, date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('ccb_private_funds.update', { id, date, type, amount_cents }, 'ccb_private_funds')
Promise.resolve().then(() => pushWeeklyDaily(date)).catch(() => {})
}
const deleteFlow = id => {
try {
const row = db.prepare(`SELECT date FROM fund_flows WHERE id=?`).get(id)
deleteAllAttachmentsByFlowId(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('ccb_private_funds.delete', { id }, 'ccb_private_funds')
if (row && row.date) Promise.resolve().then(() => deleteOrUpdateWeeklyDaily(row.date)).catch(() => {})
} catch (e) {
deleteAllAttachmentsByFlowId(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('ccb_private_funds.delete', { id, error: String(e.message || e) }, 'ccb_private_funds')
}
}
const normalizeRelPath = v => String(v || '').replace(/^\/+/, '').replace(/\\/g, '/')
const toPublicUrl = rel => `/uploads/${normalizeRelPath(rel)}`
const listAttachmentsByFlowId = flowId => {
const id = parseInt(flowId, 10)
if (!id) return []
return listAttachmentsByFlowStmt.all(id).map(r => ({
id: r.id,
flow_id: r.flow_id,
filename: r.filename,
path: r.path,
size_bytes: r.size_bytes,
mime: r.mime || '',
created_at: r.created_at,
url: toPublicUrl(r.path)
}))
}
const addAttachment = payload => {
const flow_id = parseInt(payload.flow_id, 10)
if (!flow_id) throw new Error('bad_flow_id')
const filename = String(payload.filename || 'file.bin').trim() || 'file.bin'
const p = normalizeRelPath(payload.path || '')
if (!p) throw new Error('bad_path')
const size_bytes = Math.max(0, parseInt(payload.size_bytes, 10) || 0)
const mime = String(payload.mime || 'application/octet-stream')
const info = insertAttachmentStmt.run({ flow_id, filename, path: p, size_bytes, mime })
const id = Number(info.lastInsertRowid || 0)
logJSON('ccb_private_funds.attachment.create', { id, flow_id, filename, path: p, size_bytes, mime }, 'ccb_private_funds')
return id
}
const deleteAttachmentById = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.path) {
const abs = path.join(process.cwd(), 'uploads', normalizeRelPath(row.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(aid)
logJSON('ccb_private_funds.attachment.delete', { id: aid }, 'ccb_private_funds')
}
const deleteAllAttachmentsByFlowId = flowId => {
const fid = parseInt(flowId, 10)
if (!fid) return
const rows = listAttachmentsByFlowStmt.all(fid)
for (const r of rows) {
if (r && r.path) {
const abs = path.join(process.cwd(), 'uploads', normalizeRelPath(r.path))
try { fs.unlinkSync(abs) } catch {}
}
}
deleteAttachmentsByFlowStmt.run(fid)
logJSON('ccb_private_funds.attachment.delete_all', { flow_id: fid, count: rows.length }, 'ccb_private_funds')
}
module.exports = {
getConfig,
setConfig,
listFlows,
createFlow,
updateFlowById,
deleteFlow,
listAttachmentsByFlowId,
addAttachment,
deleteAttachmentById
}
+235
View File
@@ -0,0 +1,235 @@
const { db } = require('./db')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const cfgPath = path.join(process.cwd(), 'config', 'cloud.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex') }, null, 2))
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const deriveKey = () => {
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.CLOUD_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
return crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
}
const enc = plain => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain||''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(`CREATE TABLE IF NOT EXISTS cloud_operators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
login_account TEXT,
login_password TEXT,
mobile TEXT,
contact_person TEXT,
remark TEXT,
is_company_verified INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_ecs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL,
server_name TEXT,
spec_text TEXT,
region TEXT,
zone TEXT,
rdp_ip TEXT,
rdp_user TEXT,
rdp_password TEXT,
open_ports_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_vas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL,
name TEXT NOT NULL,
config_text TEXT,
access_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_renew (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject_id INTEGER NOT NULL,
paid_date TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
invoice_path TEXT,
payer_company TEXT,
expire_date TEXT,
next_expected_amount_cents INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject_id INTEGER NOT NULL,
issue_text TEXT,
solution_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(note_id) REFERENCES cloud_notes(id) ON DELETE CASCADE
);`)
const ensureCols = () => {
const hasCol = (table, name) => db.prepare(`PRAGMA table_info(${table})`).all().some(r => r.name === name)
if (!hasCol('cloud_operators','login_password_enc')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_enc BLOB`)
if (!hasCol('cloud_operators','login_password_iv')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_iv BLOB`)
if (!hasCol('cloud_operators','login_password_tag')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_tag BLOB`)
if (!hasCol('cloud_operators','remark')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN remark TEXT`)
if (!hasCol('cloud_ecs','rdp_password_enc')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_enc BLOB`)
if (!hasCol('cloud_ecs','rdp_password_iv')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_iv BLOB`)
if (!hasCol('cloud_ecs','rdp_password_tag')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_tag BLOB`)
if (!hasCol('cloud_ecs','server_name')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN server_name TEXT`)
if (!hasCol('cloud_vas','access_text_enc')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_enc BLOB`)
if (!hasCol('cloud_vas','access_text_iv')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_iv BLOB`)
if (!hasCol('cloud_vas','access_text_tag')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_tag BLOB`)
}
ensureCols()
const migrateExisting = () => {
const ops = db.prepare(`SELECT id, login_password, login_password_enc FROM cloud_operators`).all()
for (const r of ops) {
if (r.login_password && !r.login_password_enc) {
const e = enc(r.login_password)
db.prepare(`UPDATE cloud_operators SET login_password='', login_password_enc=?, login_password_iv=?, login_password_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
const ecs = db.prepare(`SELECT id, rdp_password, rdp_password_enc FROM cloud_ecs`).all()
for (const r of ecs) {
if (r.rdp_password && !r.rdp_password_enc) {
const e = enc(r.rdp_password)
db.prepare(`UPDATE cloud_ecs SET rdp_password='', rdp_password_enc=?, rdp_password_iv=?, rdp_password_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
const vas = db.prepare(`SELECT id, access_text, access_text_enc FROM cloud_vas`).all()
for (const r of vas) {
if (r.access_text && !r.access_text_enc) {
const e = enc(r.access_text)
db.prepare(`UPDATE cloud_vas SET access_text='', access_text_enc=?, access_text_iv=?, access_text_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
}
migrateExisting()
const listOperators = db.prepare(`SELECT * FROM cloud_operators ORDER BY id DESC`)
const createOperatorStmt = db.prepare(`INSERT INTO cloud_operators (name, login_account, login_password, mobile, contact_person, remark, is_company_verified, login_password_enc, login_password_iv, login_password_tag) VALUES (@name, @login_account, '', @mobile, @contact_person, @remark, @is_company_verified, @login_password_enc, @login_password_iv, @login_password_tag)`)
const updateOperatorStmt = db.prepare(`UPDATE cloud_operators SET name=@name, login_account=@login_account, login_password='', mobile=@mobile, contact_person=@contact_person, remark=@remark, is_company_verified=@is_company_verified, login_password_enc=@login_password_enc, login_password_iv=@login_password_iv, login_password_tag=@login_password_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteOperatorStmt = db.prepare(`DELETE FROM cloud_operators WHERE id=?`)
const listEcs = db.prepare(`SELECT * FROM cloud_ecs ORDER BY id DESC`)
const listEcsByOperator = db.prepare(`SELECT * FROM cloud_ecs WHERE operator_id=? ORDER BY id DESC`)
const createEcsStmt = db.prepare(`INSERT INTO cloud_ecs (operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text, rdp_password_enc, rdp_password_iv, rdp_password_tag) VALUES (@operator_id, @server_name, @spec_text, @region, @zone, @rdp_ip, @rdp_user, '', @open_ports_text, @rdp_password_enc, @rdp_password_iv, @rdp_password_tag)`)
const updateEcsStmt = db.prepare(`UPDATE cloud_ecs SET operator_id=@operator_id, server_name=@server_name, spec_text=@spec_text, region=@region, zone=@zone, rdp_ip=@rdp_ip, rdp_user=@rdp_user, rdp_password='', open_ports_text=@open_ports_text, rdp_password_enc=@rdp_password_enc, rdp_password_iv=@rdp_password_iv, rdp_password_tag=@rdp_password_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteEcsStmt = db.prepare(`DELETE FROM cloud_ecs WHERE id=?`)
const listVas = db.prepare(`SELECT * FROM cloud_vas ORDER BY id DESC`)
const listVasByOperator = db.prepare(`SELECT * FROM cloud_vas WHERE operator_id=? ORDER BY id DESC`)
const createVasStmt = db.prepare(`INSERT INTO cloud_vas (operator_id, name, config_text, access_text, access_text_enc, access_text_iv, access_text_tag) VALUES (@operator_id, @name, @config_text, '', @access_text_enc, @access_text_iv, @access_text_tag)`)
const updateVasStmt = db.prepare(`UPDATE cloud_vas SET operator_id=@operator_id, name=@name, config_text=@config_text, access_text='', access_text_enc=@access_text_enc, access_text_iv=@access_text_iv, access_text_tag=@access_text_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteVasStmt = db.prepare(`DELETE FROM cloud_vas WHERE id=?`)
const listRenew = db.prepare(`SELECT * FROM cloud_renew ORDER BY paid_date DESC, id DESC`)
const listRenewBySubject = db.prepare(`SELECT * FROM cloud_renew WHERE subject_type=? AND subject_id=? ORDER BY paid_date DESC, id DESC`)
const createRenewStmt = db.prepare(`INSERT INTO cloud_renew (subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents) VALUES (@subject_type, @subject_id, @paid_date, @amount_cents, @invoice_path, @payer_company, @expire_date, @next_expected_amount_cents)`)
const updateRenewStmt = db.prepare(`UPDATE cloud_renew SET subject_type=@subject_type, subject_id=@subject_id, paid_date=@paid_date, amount_cents=@amount_cents, invoice_path=@invoice_path, payer_company=@payer_company, expire_date=@expire_date, next_expected_amount_cents=@next_expected_amount_cents, updated_at=datetime('now') WHERE id=@id`)
const deleteRenewStmt = db.prepare(`DELETE FROM cloud_renew WHERE id=?`)
const listNotes = db.prepare(`SELECT * FROM cloud_notes ORDER BY id DESC`)
const listNotesBySubject = db.prepare(`SELECT * FROM cloud_notes WHERE subject_type=? AND subject_id=? ORDER BY id DESC`)
const createNoteStmt = db.prepare(`INSERT INTO cloud_notes (subject_type, subject_id, issue_text, solution_text) VALUES (@subject_type, @subject_id, @issue_text, @solution_text)`)
const updateNoteStmt = db.prepare(`UPDATE cloud_notes SET subject_type=@subject_type, subject_id=@subject_id, issue_text=@issue_text, solution_text=@solution_text, updated_at=datetime('now') WHERE id=@id`)
const deleteNoteStmt = db.prepare(`DELETE FROM cloud_notes WHERE id=?`)
const listAttachmentsByNote = db.prepare(`SELECT * FROM cloud_attachments WHERE note_id=? ORDER BY id ASC`)
const createAttachmentStmt = db.prepare(`INSERT INTO cloud_attachments (note_id, filename, file_path, file_size) VALUES (@note_id, @filename, @file_path, @file_size)`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM cloud_attachments WHERE id=?`)
const listOperatorsFn = () => listOperators.all().map(r => { const lp = (r.login_password_enc && r.login_password_iv && r.login_password_tag) ? dec(r.login_password_enc, r.login_password_iv, r.login_password_tag) : (r.login_password || '') ; r.login_password = lp; return r })
const createOperator = payload => { const e = enc(payload.login_password||''); const next = Object.assign({}, payload, { login_password_enc: e.ct, login_password_iv: e.iv, login_password_tag: e.tag }); createOperatorStmt.run(next); logJSON('cloud.operator.create', { name: payload.name }, 'cloud') }
const updateOperator = payload => { const e = enc(payload.login_password||''); const next = Object.assign({}, payload, { login_password_enc: e.ct, login_password_iv: e.iv, login_password_tag: e.tag }); updateOperatorStmt.run(next); logJSON('cloud.operator.update', { id: payload.id }, 'cloud') }
const deleteOperator = id => { deleteOperatorStmt.run(id); logJSON('cloud.operator.delete', { id }, 'cloud') }
const listEcsFn = (operator_id) => (operator_id ? listEcsByOperator.all(operator_id) : listEcs.all()).map(r => { const pw = (r.rdp_password_enc && r.rdp_password_iv && r.rdp_password_tag) ? dec(r.rdp_password_enc, r.rdp_password_iv, r.rdp_password_tag) : (r.rdp_password || '') ; r.rdp_password = pw; return r })
const createEcs = payload => { const e = enc(payload.rdp_password||''); const next = Object.assign({}, payload, { rdp_password_enc: e.ct, rdp_password_iv: e.iv, rdp_password_tag: e.tag }); createEcsStmt.run(next); logJSON('cloud.ecs.create', { operator_id: payload.operator_id }, 'cloud') }
const updateEcs = payload => { const e = enc(payload.rdp_password||''); const next = Object.assign({}, payload, { rdp_password_enc: e.ct, rdp_password_iv: e.iv, rdp_password_tag: e.tag }); updateEcsStmt.run(next); logJSON('cloud.ecs.update', { id: payload.id }, 'cloud') }
const deleteEcs = id => { deleteEcsStmt.run(id); logJSON('cloud.ecs.delete', { id }, 'cloud') }
const listVasFn = (operator_id) => (operator_id ? listVasByOperator.all(operator_id) : listVas.all()).map(r => { const tx = (r.access_text_enc && r.access_text_iv && r.access_text_tag) ? dec(r.access_text_enc, r.access_text_iv, r.access_text_tag) : (r.access_text || '') ; r.access_text = tx; return r })
const createVas = payload => { const e = enc(payload.access_text||''); const next = Object.assign({}, payload, { access_text_enc: e.ct, access_text_iv: e.iv, access_text_tag: e.tag }); createVasStmt.run(next); logJSON('cloud.vas.create', { operator_id: payload.operator_id }, 'cloud') }
const updateVas = payload => { const e = enc(payload.access_text||''); const next = Object.assign({}, payload, { access_text_enc: e.ct, access_text_iv: e.iv, access_text_tag: e.tag }); updateVasStmt.run(next); logJSON('cloud.vas.update', { id: payload.id }, 'cloud') }
const deleteVas = id => { deleteVasStmt.run(id); logJSON('cloud.vas.delete', { id }, 'cloud') }
const listRenewFn = (subject_type, subject_id) => (subject_type && subject_id) ? listRenewBySubject.all(subject_type, subject_id) : listRenew.all()
const createRenew = payload => { createRenewStmt.run(payload); logJSON('cloud.renew.create', payload, 'cloud') }
const updateRenew = payload => { updateRenewStmt.run(payload); logJSON('cloud.renew.update', payload, 'cloud') }
const deleteRenew = id => { deleteRenewStmt.run(id); logJSON('cloud.renew.delete', { id }, 'cloud') }
const listNotesFn = (subject_type, subject_id) => {
const notes = (subject_type && subject_id) ? listNotesBySubject.all(subject_type, subject_id) : listNotes.all()
return notes.map(n => {
n.attachments = listAttachmentsByNote.all(n.id)
return n
})
}
const createNote = payload => {
const info = createNoteStmt.run(payload)
logJSON('cloud.note.create', { id: info.lastInsertRowid, ...payload }, 'cloud')
return info.lastInsertRowid
}
const updateNote = payload => {
updateNoteStmt.run(payload)
logJSON('cloud.note.update', payload, 'cloud')
}
const deleteNote = id => {
// Attachments are cascade deleted, but files remain. We should delete files ideally.
// For simplicity, we just delete records. A cleanup job can remove orphaned files.
deleteNoteStmt.run(id)
logJSON('cloud.note.delete', { id }, 'cloud')
}
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
logJSON('cloud.attachment.create', { id: info.lastInsertRowid, ...payload }, 'cloud')
return info.lastInsertRowid
}
const deleteAttachment = id => {
deleteAttachmentStmt.run(id)
logJSON('cloud.attachment.delete', { id }, 'cloud')
}
module.exports = { listOperatorsFn, createOperator, updateOperator, deleteOperator, listEcsFn, createEcs, updateEcs, deleteEcs, listVasFn, createVas, updateVas, deleteVas, listRenewFn, createRenew, updateRenew, deleteRenew, listNotesFn, createNote, updateNote, deleteNote, createAttachment, deleteAttachment }
+974
View File
@@ -0,0 +1,974 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const axios = require('axios')
const { ImapFlow } = require('imapflow')
const { simpleParser } = require('mailparser')
const { db } = require('./db')
const { logJSON } = require('./logger')
const cfgPath = path.join(process.cwd(), 'config', 'cloud_balance_watch.json')
const defaultConfig = () => {
const currentPort = Number(process.env.PORT || '8976') || 8976
return {
_comment: '阿里云欠费邮件监听与分发配置',
enable_listener_comment: '是否启用欠费邮件监听服务(true/false)',
enable_listener: true,
automation_port_comment: '自动服务监听端口,仅当当前 PORT 与此值一致时才启动后台监听',
automation_port: 8977,
provider_comment: '邮箱提供方标识,仅用于说明',
provider: 'aliyun_qiye_mail',
mailbox_comment: '要监听的邮箱文件夹名称',
mailbox: 'INBOX',
reconnect_delay_ms_comment: 'IMAP 断开后的重连等待毫秒数',
reconnect_delay_ms: 15000,
poll_interval_ms_comment: '兜底轮询间隔毫秒数,用于弥补 IDLE 掉线或事件漏触发',
poll_interval_ms: 120000,
health_stale_ms_comment: '超过该毫秒数未成功扫描则视为监听异常',
health_stale_ms: 300000,
fetch_batch_comment: '每次补扫最多处理多少封新邮件,避免长时间阻塞',
fetch_batch: 50,
imap_comment: 'IMAP 收信配置(敏感信息请妥善保管)',
imap: {
host_comment: 'IMAP 服务器地址',
host: 'imap.qiye.aliyun.com',
port_comment: 'IMAP 端口,SSL 通常为 993',
port: 993,
secure_comment: '是否使用 SSL/TLS',
secure: true,
user_comment: '邮箱账号',
user: 'yangxiangyuan@umer.com.cn',
pass_comment: '邮箱授权码或应用密码(敏感,请勿泄露),从环境变量读取',
pass: process.env.YANGXIANGYUAN_EMAIL_PASS || 'y7FKf48oodBj3Bcb'
},
match_rules_comment: '邮件匹配规则,只抓指定发件人的可用额度/欠费类通知',
match_rules: {
sender_exact_comment: '发件人邮箱精确匹配',
sender_exact: 'system@notice.aliyun.com',
subject_keywords_comment: '主题模糊匹配关键词,命中任意一个即可参与判定',
subject_keywords: [
'可用额度',
'预警值',
'余额',
'欠费',
'提醒'
],
body_keywords_comment: '正文二次确认关键词,降低主题改名时的漏报风险',
body_keywords: [
'阿里云',
'可用额度',
'预警值',
'余额',
'欠费'
]
},
calendar_sync_enabled_comment: '是否启用同步到日历提醒',
calendar_sync_enabled: true,
calendar_api_base_comment: '日历提醒写入地址,沿用现有 /api/calendar/events 口径',
calendar_api_base: `http://127.0.0.1:${currentPort}/api/calendar/events`,
weekly_sync_enabled_comment: '是否启用同步到周报系统(按要求默认预留,不发送)',
weekly_sync_enabled: false,
weekly_api_base_comment: '周报系统基础地址(不含 /ext/event)',
weekly_api_base: `http://127.0.0.1:${currentPort}/api/weekly`,
weekly_key_comment: '推送到周报 /api/weekly/ext/event 使用的密钥',
weekly_key: process.env.CLOUD_BALANCE_WATCH_WEEKLY_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
}
}
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify(defaultConfig(), null, 2))
const readConfig = () => {
try {
return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
} catch {
return defaultConfig()
}
}
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_key TEXT NOT NULL UNIQUE,
message_id TEXT,
message_uid INTEGER,
mailbox TEXT,
sender TEXT NOT NULL,
subject TEXT NOT NULL,
subject_norm TEXT,
received_at TEXT,
text_body TEXT,
html_body TEXT,
raw_headers TEXT,
match_reason TEXT,
risk_level TEXT DEFAULT 'medium',
seen_marked INTEGER DEFAULT 0,
seen_marked_at TEXT,
calendar_event_id TEXT,
calendar_sync_status TEXT DEFAULT 'pending',
calendar_sync_error TEXT,
calendar_sync_updated_at TEXT,
weekly_external_id TEXT,
weekly_sync_status TEXT DEFAULT 'disabled',
weekly_sync_error TEXT,
weekly_sync_updated_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_dispatch_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER,
channel TEXT NOT NULL,
status TEXT NOT NULL,
detail TEXT,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_message_uid ON cloud_balance_alerts(message_uid);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_mailbox_uid ON cloud_balance_alerts(mailbox, message_uid);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_message_id ON cloud_balance_alerts(message_id);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_received_at ON cloud_balance_alerts(received_at DESC);`)
const getStateStmt = db.prepare(`SELECT value FROM cloud_balance_state WHERE key = ?`)
const setStateStmt = db.prepare(`INSERT INTO cloud_balance_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`)
const insertDispatchLogStmt = db.prepare(`INSERT INTO cloud_balance_dispatch_logs (alert_id, channel, status, detail) VALUES (@alert_id, @channel, @status, @detail)`)
const countAlertsStmt = db.prepare(`SELECT COUNT(1) AS total FROM cloud_balance_alerts WHERE (? = '' OR subject LIKE ? OR text_body LIKE ?)` )
const listAlertsStmt = db.prepare(`SELECT id, sender, subject, received_at, text_body, html_body, risk_level, match_reason, seen_marked, seen_marked_at, calendar_event_id, calendar_sync_status, calendar_sync_error, weekly_external_id, weekly_sync_status, weekly_sync_error, created_at, updated_at
FROM cloud_balance_alerts
WHERE (? = '' OR subject LIKE ? OR text_body LIKE ?)
ORDER BY datetime(received_at) DESC, id DESC
LIMIT ? OFFSET ?`)
const getAlertByIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE id = ?`)
const getAlertByKeyStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE alert_key = ?`)
const getAlertByMailboxUidStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE mailbox = ? AND message_uid = ? ORDER BY id DESC LIMIT 1`)
const getAlertByMessageIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE message_id = ? ORDER BY id DESC LIMIT 1`)
const getAlertByCalendarIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE calendar_event_id = ?`)
const insertAlertStmt = db.prepare(`INSERT INTO cloud_balance_alerts (
alert_key, message_id, message_uid, mailbox, sender, subject, subject_norm, received_at, text_body, html_body, raw_headers, match_reason, risk_level, seen_marked, seen_marked_at, calendar_event_id, weekly_external_id, calendar_sync_status, weekly_sync_status
) VALUES (
@alert_key, @message_id, @message_uid, @mailbox, @sender, @subject, @subject_norm, @received_at, @text_body, @html_body, @raw_headers, @match_reason, @risk_level, @seen_marked, @seen_marked_at, @calendar_event_id, @weekly_external_id, @calendar_sync_status, @weekly_sync_status
)`)
const updateAlertStmt = db.prepare(`UPDATE cloud_balance_alerts SET
message_id = @message_id,
message_uid = @message_uid,
mailbox = @mailbox,
sender = @sender,
subject = @subject,
subject_norm = @subject_norm,
received_at = @received_at,
text_body = @text_body,
html_body = @html_body,
raw_headers = @raw_headers,
match_reason = @match_reason,
risk_level = @risk_level,
updated_at = datetime('now')
WHERE id = @id`)
const updateSeenStmt = db.prepare(`UPDATE cloud_balance_alerts SET seen_marked = 1, seen_marked_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`)
const updateCalendarSyncStmt = db.prepare(`UPDATE cloud_balance_alerts SET
calendar_event_id = @calendar_event_id,
calendar_sync_status = @calendar_sync_status,
calendar_sync_error = @calendar_sync_error,
calendar_sync_updated_at = datetime('now'),
updated_at = datetime('now')
WHERE id = @id`)
const updateWeeklySyncStmt = db.prepare(`UPDATE cloud_balance_alerts SET
weekly_external_id = @weekly_external_id,
weekly_sync_status = @weekly_sync_status,
weekly_sync_error = @weekly_sync_error,
weekly_sync_updated_at = datetime('now'),
updated_at = datetime('now')
WHERE id = @id`)
const updateAlertTextBodyStmt = db.prepare(`UPDATE cloud_balance_alerts SET
text_body = @text_body,
updated_at = datetime('now')
WHERE id = @id`)
const listAlertsMissingTextStmt = db.prepare(`SELECT id, html_body FROM cloud_balance_alerts WHERE (text_body IS NULL OR trim(text_body) = '') AND html_body IS NOT NULL AND trim(html_body) <> ''`)
const recentAlertsSummaryStmt = db.prepare(`SELECT
COUNT(1) AS total,
SUM(CASE WHEN risk_level = 'high' THEN 1 ELSE 0 END) AS high_risk_count,
SUM(CASE WHEN calendar_sync_status = 'ok' THEN 1 ELSE 0 END) AS calendar_ok_count,
SUM(CASE WHEN calendar_sync_status = 'error' THEN 1 ELSE 0 END) AS calendar_error_count,
SUM(CASE WHEN weekly_sync_status = 'error' THEN 1 ELSE 0 END) AS weekly_error_count
FROM cloud_balance_alerts`)
const listenerState = {
enabled: false,
running: false,
connected: false,
mailbox: '',
current_port: Number(process.env.PORT || '8976') || 8976,
last_error: '',
last_connect_at: '',
last_scan_started_at: '',
last_scan_finished_at: '',
last_scan_reason: '',
reconnect_scheduled: false
}
let client = null
let pollTimer = null
let reconnectTimer = null
let scanPromise = null
let startInvoked = false
const getScanRuntimeState = () => ({
scan_in_progress: !!scanPromise,
last_scan_started_at: listenerState.last_scan_started_at || '',
last_scan_finished_at: listenerState.last_scan_finished_at || '',
last_scan_reason: listenerState.last_scan_reason || '',
last_error: listenerState.last_error || ''
})
const isoNow = () => new Date().toISOString()
const safeString = (value) => String(value == null ? '' : value).trim()
const normalizeSpace = (value) => safeString(value).replace(/\s+/g, ' ')
const sha1 = value => crypto.createHash('sha1').update(String(value || '')).digest('hex')
// #region debug-point cloud-balance-debug-helper
const reportCloudBalanceDebug = (hypothesisId, location, msg, data = {}) => {
try {
const envPath = path.join(process.cwd(), '.dbg', 'cloud-balance-stuck.env')
let debugUrl = 'http://127.0.0.1:7777/event'
let sessionId = 'cloud-balance-stuck'
try {
const envText = fs.readFileSync(envPath, 'utf8')
debugUrl = (envText.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || debugUrl
sessionId = (envText.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || sessionId
} catch {}
fetch(debugUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
runId: 'pre-fix',
hypothesisId,
location,
msg,
data,
ts: Date.now()
})
}).catch(() => {})
} catch {}
}
// #endregion
const finalizeScanState = ({ highestCommittedUid, fetched, matched, inserted, updated, finishedAt, summaryFinishedAt }) => {
const finalFinishedAt = safeString(finishedAt) || isoNow()
const finalSummary = {
fetched: Number(fetched || 0) || 0,
matched: Number(matched || 0) || 0,
inserted: Number(inserted || 0) || 0,
updated: Number(updated || 0) || 0,
last_uid: Number(highestCommittedUid || 0) || 0,
finished_at: safeString(summaryFinishedAt) || finalFinishedAt
}
setState('last_uid', String(Number(highestCommittedUid || 0) || 0))
listenerState.last_scan_finished_at = finalFinishedAt
setState('last_scan_finished_at', finalFinishedAt)
setState('last_scan_summary', finalSummary)
}
const setState = (key, value) => setStateStmt.run({ key, value: typeof value === 'string' ? value : JSON.stringify(value) })
const getState = (key, fallback = '') => {
const row = getStateStmt.get(key)
return row && row.value != null ? row.value : fallback
}
const writeDispatchLog = (alertId, channel, status, detail) => {
try {
insertDispatchLogStmt.run({
alert_id: alertId || null,
channel: safeString(channel) || 'unknown',
status: safeString(status) || 'unknown',
detail: typeof detail === 'string' ? detail : JSON.stringify(detail || {})
})
} catch {}
}
const shouldStartListener = (cfg = readConfig()) => {
if (cfg.enable_listener === false) return false
const currentPort = Number(process.env.PORT || '8976') || 8976
const automationPort = Number(cfg.automation_port || 8977) || 8977
return currentPort === automationPort
}
const htmlToText = html => safeString(html)
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, '\'')
.replace(/\s+/g, ' ')
.trim()
const normalizeMailBody = (textBody, htmlBody) => {
const text = normalizeSpace(textBody)
if (text) return text
return normalizeSpace(htmlToText(htmlBody))
}
const backfillAlertTextBodies = () => {
try {
const rows = listAlertsMissingTextStmt.all()
rows.forEach(row => {
const fallback = normalizeMailBody('', row.html_body || '')
if (fallback) updateAlertTextBodyStmt.run({ id: row.id, text_body: fallback })
})
} catch {}
}
backfillAlertTextBodies()
const extractSender = parsed => {
try {
const address = parsed && parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].address
if (address) return safeString(address).toLowerCase()
} catch {}
return ''
}
const matchesAlert = (parsed, cfg) => {
const rules = cfg.match_rules || {}
const sender = extractSender(parsed)
const senderExact = safeString(rules.sender_exact).toLowerCase()
const subject = normalizeSpace(parsed && parsed.subject)
const textBody = normalizeMailBody(parsed && parsed.text, parsed && parsed.html)
const htmlBody = safeString(parsed && parsed.html)
const fullBody = normalizeSpace(`${textBody}\n${htmlToText(htmlBody)}`)
const subjectLower = subject.toLowerCase()
const bodyLower = fullBody.toLowerCase()
const subjectKeywords = Array.isArray(rules.subject_keywords) ? rules.subject_keywords.map(item => safeString(item).toLowerCase()).filter(Boolean) : []
const bodyKeywords = Array.isArray(rules.body_keywords) ? rules.body_keywords.map(item => safeString(item).toLowerCase()).filter(Boolean) : []
const subjectHits = subjectKeywords.filter(keyword => subjectLower.includes(keyword))
const bodyHits = bodyKeywords.filter(keyword => bodyLower.includes(keyword))
const bodyHitsStrict = bodyHits.filter(keyword => keyword !== '阿里云')
const senderMatched = !!senderExact && sender === senderExact
const subjectMatched = subjectHits.length > 0
const bodyMatched = bodyHitsStrict.length > 0
const matched = senderMatched && (subjectMatched || bodyMatched)
const riskLevel = subjectMatched && bodyMatched ? 'high' : (matched ? 'medium' : 'low')
const reasons = []
if (senderMatched) reasons.push(`发件人=${sender}`)
if (subjectHits.length) reasons.push(`标题命中:${subjectHits.join('|')}`)
if (bodyHitsStrict.length) reasons.push(`正文命中:${bodyHitsStrict.join('|')}`)
return {
matched,
sender,
subject,
textBody,
htmlBody,
riskLevel,
matchReason: reasons.join(';')
}
}
const buildHeadersSnapshot = parsed => {
const snap = {}
try {
snap.messageId = safeString(parsed && parsed.messageId)
snap.date = parsed && parsed.date ? new Date(parsed.date).toISOString() : ''
snap.from = extractSender(parsed)
snap.to = parsed && parsed.to && parsed.to.text ? safeString(parsed.to.text) : ''
} catch {}
return JSON.stringify(snap)
}
const buildAlertKey = payload => {
const mailbox = safeString(payload.mailbox || 'INBOX')
const messageUid = Number(payload.message_uid || 0) || 0
const messageId = safeString(payload.message_id)
let raw = ''
if (mailbox && messageUid > 0) raw = `mailbox-uid|${mailbox}|${messageUid}`
else if (messageId) raw = `message-id|${messageId}`
else raw = `${payload.sender}|${payload.subject_norm}|${payload.received_at}|${payload.message_uid || ''}`
return sha1(raw)
}
const toSqliteDateTime = value => {
if (!value) return isoNow().replace('T', ' ').slice(0, 19)
try {
return new Date(value).toISOString().replace('T', ' ').slice(0, 19)
} catch {
return isoNow().replace('T', ' ').slice(0, 19)
}
}
const upsertAlert = payload => {
const alertKey = buildAlertKey(payload)
const next = Object.assign({}, payload, {
alert_key: alertKey,
subject_norm: normalizeSpace(payload.subject).toLowerCase(),
received_at: toSqliteDateTime(payload.received_at),
calendar_event_id: payload.calendar_event_id || `cloud-arrear-${alertKey.slice(0, 16)}`,
weekly_external_id: payload.weekly_external_id || `cloud-arrear-${alertKey.slice(0, 16)}`,
calendar_sync_status: payload.calendar_sync_status || 'pending',
weekly_sync_status: payload.weekly_sync_status || 'disabled',
seen_marked: payload.seen_marked ? 1 : 0,
seen_marked_at: payload.seen_marked ? toSqliteDateTime(payload.seen_marked_at || isoNow()) : null
})
const existing = (() => {
const mailbox = safeString(next.mailbox || 'INBOX')
const messageUid = Number(next.message_uid || 0) || 0
const messageId = safeString(next.message_id)
if (mailbox && messageUid > 0) {
const row = getAlertByMailboxUidStmt.get(mailbox, messageUid)
if (row) return row
}
if (messageId) {
const row = getAlertByMessageIdStmt.get(messageId)
if (row) return row
}
return getAlertByKeyStmt.get(alertKey)
})()
if (existing) {
updateAlertStmt.run(Object.assign({}, next, { id: existing.id }))
return Object.assign({}, getAlertByIdStmt.get(existing.id), { created: false })
}
const info = insertAlertStmt.run(next)
return Object.assign({}, getAlertByIdStmt.get(info.lastInsertRowid), { created: true })
}
const buildCalendarPayload = alert => {
const start = (alert.received_at || '').replace(' ', 'T')
const detailLines = [
`来源邮箱: ${alert.sender}`,
`接收时间: ${alert.received_at || ''}`,
`本地告警ID: ${alert.id}`,
`关联主键: ${alert.calendar_event_id}`,
`匹配原因: ${alert.match_reason || ''}`,
'',
'邮件正文:',
safeString(alert.text_body || htmlToText(alert.html_body || '')) || '(空)'
]
return {
id: alert.calendar_event_id,
title: `[欠费通知] ${safeString(alert.subject) || '阿里云额度提醒'}`,
content: detailLines.join('\n'),
start,
end: start,
remindAt: start,
allDay: 0,
deleted: false,
createdAt: isoNow(),
updatedAt: isoNow()
}
}
const dispatchCalendar = async (alert, cfg = readConfig()) => {
const enabled = cfg.calendar_sync_enabled !== false
const calendarEventId = alert.calendar_event_id || `cloud-arrear-${alert.alert_key.slice(0, 16)}`
if (!enabled) {
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'disabled', calendar_sync_error: '' })
return { ok: false, skipped: true, reason: 'calendar_disabled' }
}
const url = safeString(cfg.calendar_api_base)
if (!url) {
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'error', calendar_sync_error: 'calendar_api_base_missing' })
return { ok: false, error: 'calendar_api_base_missing' }
}
try {
await axios.post(url, buildCalendarPayload(Object.assign({}, alert, { calendar_event_id: calendarEventId })), {
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'ok', calendar_sync_error: '' })
writeDispatchLog(alert.id, 'calendar', 'ok', { url, event_id: calendarEventId })
return { ok: true, event_id: calendarEventId }
} catch (e) {
const error = String(e && e.message ? e.message : e)
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'error', calendar_sync_error: error })
writeDispatchLog(alert.id, 'calendar', 'error', { url, error })
return { ok: false, error, event_id: calendarEventId }
}
}
const dispatchWeekly = async (alert, cfg = readConfig()) => {
const externalId = alert.weekly_external_id || `cloud-arrear-${alert.alert_key.slice(0, 16)}`
if (cfg.weekly_sync_enabled !== true) {
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'disabled', weekly_sync_error: '' })
return { ok: false, skipped: true, reason: 'weekly_disabled' }
}
const base = safeString(cfg.weekly_api_base)
if (!base) {
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'error', weekly_sync_error: 'weekly_api_base_missing' })
return { ok: false, error: 'weekly_api_base_missing' }
}
try {
await axios.post(`${base.replace(/\/$/, '')}/ext/event`, {
key: safeString(cfg.weekly_key) || process.env.SYSTEM_AUTH_PASS_93220 || '',
source: 'cloud_arrear',
external_id: externalId,
date: String(alert.received_at || '').slice(0, 10),
title: `[欠费通知] ${safeString(alert.subject) || '阿里云额度提醒'}`,
content: safeString(alert.text_body || htmlToText(alert.html_body || '')),
status_code: 'new',
status_label: '⚠️欠费通知',
deadline: (alert.received_at || '').replace(' ', 'T')
}, {
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'ok', weekly_sync_error: '' })
writeDispatchLog(alert.id, 'weekly', 'ok', { external_id: externalId })
return { ok: true, external_id: externalId }
} catch (e) {
const error = String(e && e.message ? e.message : e)
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'error', weekly_sync_error: error })
writeDispatchLog(alert.id, 'weekly', 'error', { external_id: externalId, error })
return { ok: false, error, external_id: externalId }
}
}
const markSeen = async (uid, alertId) => {
// #region debug-point cloud-balance-mark-seen-start
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:markSeen:start', '[DEBUG] mark seen start', { uid, alertId })
// #endregion
if (!client) return
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true })
updateSeenStmt.run(alertId)
// #region debug-point cloud-balance-mark-seen-done
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:markSeen:done', '[DEBUG] mark seen done', { uid, alertId })
// #endregion
}
const processParsedMessage = async (parsed, meta, cfg) => {
const match = matchesAlert(parsed, cfg)
// #region debug-point cloud-balance-process-message
reportCloudBalanceDebug('B', 'cloud_balance_watch.js:processParsedMessage', '[DEBUG] parsed message result', {
uid: meta && meta.uid,
mailbox: meta && meta.mailbox,
sender: match && match.sender,
subject: match && match.subject,
matched: !!(match && match.matched),
matchReason: match && match.matchReason
})
// #endregion
if (!match.matched) return { matched: false }
const row = upsertAlert({
message_id: safeString(parsed && parsed.messageId),
message_uid: meta.uid,
mailbox: meta.mailbox,
sender: match.sender,
subject: match.subject,
received_at: parsed && parsed.date ? parsed.date.toISOString() : isoNow(),
text_body: match.textBody,
html_body: match.htmlBody,
raw_headers: buildHeadersSnapshot(parsed),
match_reason: match.matchReason,
risk_level: match.riskLevel
})
await markSeen(meta.uid, row.id)
const fresh = getAlertByIdStmt.get(row.id)
await dispatchCalendar(fresh, cfg)
await dispatchWeekly(getAlertByIdStmt.get(row.id), cfg)
return { matched: true, created: !!row.created, id: row.id }
}
const closeClient = async () => {
const current = client
client = null
listenerState.connected = false
if (!current) return
try { current.removeAllListeners() } catch {}
try { await current.logout() } catch {}
}
const scheduleReconnect = () => {
if (reconnectTimer || !shouldStartListener(readConfig())) return
listenerState.reconnect_scheduled = true
const delay = Math.max(5000, Number(readConfig().reconnect_delay_ms || 15000) || 15000)
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
listenerState.reconnect_scheduled = false
try {
await connectClient('reconnect')
} catch {}
}, delay)
}
const connectClient = async (reason = 'manual') => {
const cfg = readConfig()
if (!shouldStartListener(cfg)) {
listenerState.enabled = false
listenerState.running = false
return false
}
await closeClient()
const imapCfg = cfg.imap || {}
const nextClient = new ImapFlow({
host: safeString(imapCfg.host),
port: Number(imapCfg.port || 993) || 993,
secure: imapCfg.secure !== false,
auth: {
user: safeString(imapCfg.user),
pass: safeString(imapCfg.pass)
},
logger: false,
disableAutoIdle: false,
clientInfo: { name: 'TRAE-Toolbox', version: '1.0.0' }
})
nextClient.on('error', err => {
listenerState.last_error = String(err && err.message ? err.message : err)
logJSON('cloud.balance.listener.error', { error: listenerState.last_error }, 'cloud')
// #region debug-point cloud-balance-listener-error
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:connectClient:error', '[DEBUG] listener error', { reason, error: listenerState.last_error })
// #endregion
})
nextClient.on('close', () => {
listenerState.connected = false
scheduleReconnect()
// #region debug-point cloud-balance-listener-close
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:connectClient:close', '[DEBUG] listener close', { reason, reconnect_scheduled: true })
// #endregion
})
nextClient.on('exists', () => {
triggerScan('exists').catch(() => {})
})
await nextClient.connect()
await nextClient.mailboxOpen(safeString(cfg.mailbox) || 'INBOX', { readOnly: false })
// #region debug-point cloud-balance-connect-done
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:connectClient:done', '[DEBUG] mailbox connected', {
reason,
mailbox: safeString(cfg.mailbox) || 'INBOX',
host: safeString(imapCfg.host),
port: Number(imapCfg.port || 993) || 993
})
// #endregion
client = nextClient
listenerState.enabled = true
listenerState.running = true
listenerState.connected = true
listenerState.mailbox = safeString(cfg.mailbox) || 'INBOX'
listenerState.last_connect_at = isoNow()
listenerState.last_error = ''
setState('last_connect_reason', reason)
return true
}
const scanMailbox = async reason => {
const cfg = readConfig()
if (!shouldStartListener(cfg)) return { ok: false, skipped: true, reason: 'not_automation_port' }
if (!client) await connectClient(reason)
listenerState.last_scan_reason = reason
listenerState.last_scan_started_at = isoNow()
setState('last_scan_started_at', listenerState.last_scan_started_at)
setState('last_scan_reason', reason)
let lastUid = Number(getState('last_uid', '0') || '0') || 0
let lastSenderHistoryUid = Number(getState('last_sender_history_uid', '0') || '0') || 0
let highestCommittedUid = lastUid
const batchLimit = Math.max(1, Number(cfg.fetch_batch || 50) || 50)
let fetched = 0
let matched = 0
let inserted = 0
let updated = 0
const processedUids = new Set()
try {
const range = `${Math.max(1, lastUid + 1)}:*`
// #region debug-point cloud-balance-scan-start
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:start', '[DEBUG] scan start', {
reason,
lastUid,
range,
batchLimit,
mailbox: listenerState.mailbox || safeString(cfg.mailbox) || 'INBOX'
})
// #endregion
const processMessage = async (message, sourceTag) => {
if (!message || !message.uid || processedUids.has(message.uid)) return
processedUids.add(message.uid)
fetched += 1
if (message.uid > highestCommittedUid) highestCommittedUid = message.uid
try {
// #region debug-point cloud-balance-message-fetch
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:message', '[DEBUG] processing message', {
uid: message.uid,
sourceTag,
fetched,
highestCommittedUid
})
// #endregion
const parsed = await simpleParser(message.source)
const result = await processParsedMessage(parsed, {
uid: message.uid,
mailbox: listenerState.mailbox || safeString(cfg.mailbox) || 'INBOX'
}, cfg)
if (result.matched) {
matched += 1
if (result.created) inserted += 1
else updated += 1
}
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.scan.message.error', { uid: message.uid, error: listenerState.last_error }, 'cloud')
// #region debug-point cloud-balance-message-error
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:scanMailbox:message:error', '[DEBUG] message error', {
uid: message && message.uid,
sourceTag,
error: listenerState.last_error
})
// #endregion
throw e
}
}
for await (const message of client.fetch(range, {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true
}, { uid: true })) {
if (!message || !message.uid) continue
if (message.uid <= lastUid) {
highestCommittedUid = Math.max(highestCommittedUid, message.uid)
continue
}
await processMessage(message, 'uid-forward')
if (fetched >= batchLimit) break
}
let legacyCandidateUids = []
if (client && listenerState.connected) {
try {
const senderExact = safeString(cfg && cfg.match_rules && cfg.match_rules.sender_exact).toLowerCase()
const searchQuery = senderExact ? { from: senderExact } : { seen: false }
const legacyUidsRaw = await client.search(searchQuery, { uid: true })
// #region debug-point cloud-balance-search-unseen
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:scanMailbox:search-unseen', '[DEBUG] legacy search result', {
reason,
lastUid,
lastSenderHistoryUid,
mode: senderExact ? 'sender-history' : 'unread-fallback',
sender: senderExact || '',
count: Array.isArray(legacyUidsRaw) ? legacyUidsRaw.length : -1
})
// #endregion
legacyCandidateUids = (Array.isArray(legacyUidsRaw) ? legacyUidsRaw : [])
.map(item => Number(item) || 0)
.filter(uid => uid > 0 && uid <= lastUid && !processedUids.has(uid) && (senderExact ? uid > lastSenderHistoryUid : true))
.sort((a, b) => a - b)
} catch (e) {
const searchError = String(e && e.message ? e.message : e)
listenerState.last_error = searchError
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:scanMailbox:search-unseen:error', '[DEBUG] unseen search error', {
reason,
lastUid,
error: searchError
})
}
}
if (legacyCandidateUids.length > 0 && client && listenerState.connected) {
for await (const message of client.fetch(legacyCandidateUids.join(','), {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true
}, { uid: true })) {
await processMessage(message, 'legacy-history')
}
const lastHistoryProcessedUid = legacyCandidateUids[legacyCandidateUids.length - 1] || 0
if (lastHistoryProcessedUid > lastSenderHistoryUid) {
lastSenderHistoryUid = lastHistoryProcessedUid
setState('last_sender_history_uid', String(lastSenderHistoryUid))
}
}
finalizeScanState({
highestCommittedUid,
fetched,
matched,
inserted,
updated
})
listenerState.last_error = ''
// #region debug-point cloud-balance-scan-finish
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:finish', '[DEBUG] scan finish', {
reason,
fetched,
matched,
inserted,
updated,
last_uid: highestCommittedUid
})
// #endregion
return { ok: true, fetched, matched, inserted, updated, last_uid: highestCommittedUid }
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.scan.error', { reason, error: listenerState.last_error }, 'cloud')
scheduleReconnect()
finalizeScanState({
highestCommittedUid,
fetched,
matched,
inserted,
updated
})
// #region debug-point cloud-balance-scan-error
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:error', '[DEBUG] scan error', {
reason,
error: listenerState.last_error,
fetched,
matched,
inserted,
updated,
last_uid: highestCommittedUid
})
// #endregion
return { ok: false, error: listenerState.last_error, fetched, matched, inserted, updated, last_uid: highestCommittedUid }
}
}
const triggerScan = async reason => {
if (scanPromise) return scanPromise
scanPromise = scanMailbox(reason).finally(() => {
scanPromise = null
})
return scanPromise
}
const requestScan = reason => {
if (scanPromise) {
// #region debug-point cloud-balance-request-already-running
reportCloudBalanceDebug('E', 'cloud_balance_watch.js:requestScan:already-running', '[DEBUG] request scan already running', {
reason,
runtime: getScanRuntimeState()
})
// #endregion
return {
ok: true,
accepted: true,
already_running: true,
message: 'scan_in_progress',
runtime: getScanRuntimeState()
}
}
scanPromise = scanMailbox(reason).finally(() => {
scanPromise = null
})
// #region debug-point cloud-balance-request-started
reportCloudBalanceDebug('E', 'cloud_balance_watch.js:requestScan:started', '[DEBUG] request scan started', {
reason,
runtime: getScanRuntimeState()
})
// #endregion
return {
ok: true,
accepted: true,
already_running: false,
message: 'scan_started',
runtime: getScanRuntimeState()
}
}
const start = async () => {
if (startInvoked) return
startInvoked = true
const cfg = readConfig()
listenerState.enabled = cfg.enable_listener !== false
if (!shouldStartListener(cfg)) {
listenerState.running = false
listenerState.connected = false
return
}
const interval = Math.max(30000, Number(cfg.poll_interval_ms || 600000) || 600000)
if (!pollTimer) {
pollTimer = setInterval(() => {
triggerScan('poll').catch(() => {})
}, interval)
}
try {
await connectClient('startup')
await triggerScan('startup')
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.start.error', { error: listenerState.last_error }, 'cloud')
scheduleReconnect()
}
}
const listAlerts = ({ page = 1, pageSize = 20, keyword = '' } = {}) => {
backfillAlertTextBodies()
const safePage = Math.max(1, parseInt(page, 10) || 1)
const safePageSize = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20))
const q = safeString(keyword)
const like = `%${q}%`
const total = (countAlertsStmt.get(q, like, like) || {}).total || 0
const rows = listAlertsStmt.all(q, like, like, safePageSize, (safePage - 1) * safePageSize).map(row => Object.assign({}, row, {
text_body: normalizeMailBody(row.text_body || '', row.html_body || '')
}))
return {
page: safePage,
page_size: safePageSize,
total,
rows
}
}
const getStatus = () => {
const cfg = readConfig()
const summary = recentAlertsSummaryStmt.get() || {}
const lastScanFinishedAt = listenerState.last_scan_finished_at || getState('last_scan_finished_at', '')
const staleMs = Math.max(60000, Number(cfg.health_stale_ms || 300000) || 300000)
let healthy = false
if (lastScanFinishedAt) {
const diff = Date.now() - new Date(lastScanFinishedAt).getTime()
healthy = Number.isFinite(diff) && diff <= staleMs
}
return {
config: {
enabled: cfg.enable_listener !== false,
automation_port: Number(cfg.automation_port || 8977) || 8977,
current_port: Number(process.env.PORT || '8976') || 8976,
mailbox: safeString(cfg.mailbox) || 'INBOX',
sender_exact: safeString(cfg.match_rules && cfg.match_rules.sender_exact),
calendar_sync_enabled: cfg.calendar_sync_enabled !== false,
weekly_sync_enabled: cfg.weekly_sync_enabled === true
},
listener: Object.assign({}, listenerState, {
healthy,
scan_in_progress: !!scanPromise
}),
state: {
last_uid: Number(getState('last_uid', '0') || '0') || 0,
last_scan_summary: (() => {
try { return JSON.parse(getState('last_scan_summary', '{}')) } catch { return {} }
})()
},
summary: {
total: summary.total || 0,
high_risk_count: summary.high_risk_count || 0,
calendar_ok_count: summary.calendar_ok_count || 0,
calendar_error_count: summary.calendar_error_count || 0,
weekly_error_count: summary.weekly_error_count || 0
}
}
}
const retryCalendarById = async id => {
const alert = getAlertByIdStmt.get(id)
if (!alert) throw new Error('alert_not_found')
return dispatchCalendar(alert, readConfig())
}
const retryWeeklyById = async id => {
const alert = getAlertByIdStmt.get(id)
if (!alert) throw new Error('alert_not_found')
return dispatchWeekly(alert, readConfig())
}
module.exports = {
start,
readConfig,
triggerScan,
requestScan,
listAlerts,
getAlert: id => getAlertByIdStmt.get(id),
getStatus,
retryCalendarById,
retryWeeklyById
}
+400
View File
@@ -0,0 +1,400 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'cloud_notes.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
// ensure optional columns
try {
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map(c => c.name)
if (!cols.includes('starred')) db.exec(`ALTER TABLE notes ADD COLUMN starred INTEGER DEFAULT 0`)
if (!cols.includes('pinned')) db.exec(`ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0`)
if (!cols.includes('deleted_at')) db.exec(`ALTER TABLE notes ADD COLUMN deleted_at TEXT`)
} catch {}
db.exec(`CREATE TABLE IF NOT EXISTS note_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
saved_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
sort_index INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS note_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`)
const getUserCount = db.prepare(`SELECT COUNT(1) AS c FROM users`)
const findUser = db.prepare(`SELECT * FROM users WHERE username=?`)
const createUser = db.prepare(`INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)`)
const hashPassword = (pwd, saltHex) => {
const salt = Buffer.from(saltHex, 'hex')
const key = crypto.scryptSync(String(pwd || ''), salt, 32)
return key.toString('hex')
}
const ensureAdmin = () => {
const c = getUserCount.get().c
if (c > 0) return
const salt = crypto.randomBytes(16).toString('hex')
const defaultPass = process.env.CLOUD_NOTES_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const hash = hashPassword(defaultPass, salt)
createUser.run('admin_cn', hash, salt)
}
ensureAdmin()
const login = (username, password) => {
const u = findUser.get(username)
if (!u) {
const exists = getUserCount.get().c > 0
if (exists) return null
const salt = crypto.randomBytes(16).toString('hex')
const hash = hashPassword(password, salt)
createUser.run(username, hash, salt)
return findUser.get(username)
}
const ok = u.password_hash === hashPassword(password, u.salt)
return ok ? u : null
}
const insertNote = db.prepare(`INSERT INTO notes (user_id, title, html) VALUES (@user_id, @title, @html)`)
const updateNoteStmt = db.prepare(`UPDATE notes SET title=@title, html=@html, updated_at=datetime('now') WHERE id=@id`)
const insertVersion = db.prepare(`INSERT INTO note_versions (note_id, title, html) VALUES (@note_id, @title, @html)`)
const listVersionsStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 50`)
const latestVersionStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 1`)
const createAttachmentStmt = db.prepare(`INSERT INTO attachments (note_id, filename, file_path, file_size) VALUES (@note_id, @filename, @file_path, @file_size)`)
const listAttachmentsStmt = db.prepare(`SELECT id, note_id, filename, file_path, file_size, datetime(created_at, 'localtime') AS created_at FROM attachments WHERE note_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, file_path FROM attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM attachments WHERE id=?`)
const countAttachmentByPathStmt = db.prepare(`SELECT COUNT(1) AS c FROM attachments WHERE file_path=?`)
const listNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`)
const getNoteStmt = db.prepare(`SELECT id, user_id, title, html, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE id=?`)
const searchNotesStmt = db.prepare(`SELECT id, user_id, title, html, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at, starred, pinned, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL AND (title LIKE ? OR html LIKE ?) ORDER BY updated_at DESC`)
const listTrashNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, updated_at DESC`)
const softDeleteNoteStmt = db.prepare(`UPDATE notes SET deleted_at=datetime('now') WHERE id=@id`)
const restoreNoteStmt = db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=@id`)
const hardDeleteNoteStmt = db.prepare(`DELETE FROM notes WHERE id=@id`)
const setStarStmt = db.prepare(`UPDATE notes SET starred=@starred WHERE id=@id`)
const setPinStmt = db.prepare(`UPDATE notes SET pinned=@pinned WHERE id=@id`)
const listTagsStmt = db.prepare(`SELECT id, user_id, name, color, sort_index, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at FROM tags WHERE user_id=? ORDER BY sort_index ASC, id ASC`)
const insertTagStmt = db.prepare(`INSERT INTO tags (user_id, name, color, sort_index) VALUES (@user_id, @name, @color, @sort_index)`)
const updateTagStmt = db.prepare(`UPDATE tags SET name=@name, color=@color, sort_index=@sort_index, updated_at=datetime('now') WHERE id=@id AND user_id=@user_id`)
const deleteTagStmt = db.prepare(`DELETE FROM tags WHERE id=? AND user_id=?`)
const deleteNoteTagsByTagStmt = db.prepare(`DELETE FROM note_tags WHERE tag_id=?`)
const listNoteTagsStmt = db.prepare(`SELECT tag_id FROM note_tags WHERE note_id=?`)
const deleteNoteTagsByNoteStmt = db.prepare(`DELETE FROM note_tags WHERE note_id=?`)
const insertNoteTagStmt = db.prepare(`INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)`)
const listNotesByTagStmt = db.prepare(`SELECT n.id, n.title, n.starred, n.pinned, datetime(n.updated_at, 'localtime') AS updated_at, datetime(n.created_at, 'localtime') AS created_at, LENGTH(n.html) AS size_bytes FROM notes n JOIN note_tags t ON n.id=t.note_id WHERE n.user_id=? AND n.deleted_at IS NULL AND t.tag_id=? ORDER BY n.pinned DESC, n.updated_at DESC`)
const tagStatsStmt = db.prepare(`SELECT t.id, t.user_id, t.name, t.color, t.sort_index, COUNT(nt.note_id) AS note_count FROM tags t LEFT JOIN note_tags nt ON t.id=nt.tag_id LEFT JOIN notes n ON nt.note_id=n.id AND n.deleted_at IS NULL WHERE t.user_id=? GROUP BY t.id, t.user_id, t.name, t.color, t.sort_index ORDER BY note_count DESC, t.sort_index ASC, t.id ASC`)
const untaggedCountStmt = db.prepare(`SELECT COUNT(1) AS c FROM notes n WHERE n.user_id=? AND n.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM note_tags nt WHERE nt.note_id=n.id)`)
const recentAttachmentsByUserStmt = db.prepare(`SELECT a.id, a.note_id, a.filename, a.file_path, a.file_size, datetime(a.created_at, 'localtime') AS created_at, n.title AS note_title FROM attachments a JOIN notes n ON a.note_id=n.id WHERE n.user_id=? ORDER BY a.created_at DESC LIMIT 10`)
const getSettingStmt = db.prepare(`SELECT value FROM settings WHERE key=?`)
const upsertSettingStmt = db.prepare(`INSERT INTO settings (key, value) VALUES (@key, @value) ON CONFLICT(key) DO UPDATE SET value=@value, updated_at=datetime('now')`)
const defaultTinyToolbarSettings = () => ({
undo_redo: true,
blocks_fontsize: true,
inline_format: true,
align: true,
indent: true,
lists: true,
table: true,
media: true,
insert_misc: true,
code: true,
page_tools: true,
search_visual: true,
preview_fullscreen_print: true,
cn_tools: true,
menubar: true,
upgrade_button: true
})
const normalizeTinyToolbarSettings = raw => {
const def = defaultTinyToolbarSettings()
const out = {}
Object.keys(def).forEach(k => {
out[k] = raw && Object.prototype.hasOwnProperty.call(raw, k) ? !!raw[k] : def[k]
})
return out
}
const getTinyToolbarSettings = () => {
const row = getSettingStmt.get('tiny_toolbar')
if (!row || typeof row.value !== 'string') return defaultTinyToolbarSettings()
try {
const raw = JSON.parse(row.value || '{}')
return normalizeTinyToolbarSettings(raw)
} catch {
return defaultTinyToolbarSettings()
}
}
const saveTinyToolbarSettings = settings => {
const norm = normalizeTinyToolbarSettings(settings || {})
const value = JSON.stringify(norm)
upsertSettingStmt.run({ key: 'tiny_toolbar', value })
return norm
}
const saveNote = payload => {
const id = parseInt(payload.id || '0', 10)
const user_id = parseInt(payload.user_id || '1', 10)
const title = String(payload.title || '未命名')
const html = String(payload.html || '')
const baseVersionId = parseInt(payload.base_version_id || '0', 10)
const force = !!payload.force
let noteId = 0
let versionId = 0
if (!id) {
const info = insertNote.run({ user_id, title, html })
const vinfo = insertVersion.run({ note_id: info.lastInsertRowid, title, html })
noteId = info.lastInsertRowid
versionId = vinfo.lastInsertRowid
} else {
const latest = latestVersionStmt.get(id)
const latestId = latest && latest.id ? latest.id : 0
if (baseVersionId && latestId && baseVersionId !== latestId && !force) {
return { conflict: true, latest }
}
updateNoteStmt.run({ id, title, html })
const vinfo = insertVersion.run({ note_id: id, title, html })
noteId = id
versionId = vinfo.lastInsertRowid
}
const tagIds = Array.isArray(payload.tag_ids) ? payload.tag_ids.map(x => parseInt(x, 10)).filter(x => x > 0) : []
deleteNoteTagsByNoteStmt.run(noteId)
const seen = new Set()
tagIds.forEach(tid => {
if (tid && !seen.has(tid)) {
seen.add(tid)
insertNoteTagStmt.run(noteId, tid)
}
})
return { note_id: noteId, version_id: versionId, conflict: false }
}
const listVersions = noteId => listVersionsStmt.all(noteId)
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
return info.lastInsertRowid
}
const listAttachments = noteId => listAttachmentsStmt.all(noteId)
const deleteAttachment = id => deleteAttachmentStmt.run(parseInt(id, 10))
const deleteAttachmentWithFile = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.file_path) {
try {
const refs = countAttachmentByPathStmt.get(row.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(row.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
}
deleteAttachmentStmt.run(aid)
}
const cloneAttachments = (srcNoteId, destNoteId) => {
const srcId = parseInt(srcNoteId, 10)
const dstId = parseInt(destNoteId, 10)
if (!srcId || !dstId || srcId === dstId) return
const atts = listAttachmentsStmt.all(srcId)
atts.forEach(a => {
createAttachmentStmt.run({
note_id: dstId,
filename: a.filename,
file_path: a.file_path,
file_size: a.file_size
})
})
}
const listNotes = userId => listNotesStmt.all(userId)
const getNote = id => {
const row = getNoteStmt.get(id)
if (!row) return null
const rel = listNoteTagsStmt.all(id)
row.tag_ids = rel.map(r => r.tag_id)
const latest = latestVersionStmt.get(id)
row.version_id = latest && latest.id ? latest.id : 0
row.version_saved_at = latest && latest.saved_at ? latest.saved_at : ''
return row
}
const searchNotes = (userId, keyword) => {
const kw = String(keyword || '').trim()
if (!kw) return []
const pat = `%${kw}%`
return searchNotesStmt.all(userId, pat, pat)
}
const setStar = payload => setStarStmt.run({ id: parseInt(payload.id,10), starred: payload.starred ? 1 : 0 })
const setPin = payload => setPinStmt.run({ id: parseInt(payload.id,10), pinned: payload.pinned ? 1 : 0 })
const listTrashNotes = userId => listTrashNotesStmt.all(userId)
const softDeleteNote = id => softDeleteNoteStmt.run({ id: parseInt(id, 10) })
const restoreNote = id => restoreNoteStmt.run({ id: parseInt(id, 10) })
const listTags = userId => listTagsStmt.all(userId)
const saveTags = (userId, tags) => {
const uid = parseInt(userId || '1', 10)
const existing = listTagsStmt.all(uid)
const existingById = new Map()
existing.forEach(t => { existingById.set(t.id, t) })
const tx = db.transaction(() => {
const keep = new Set()
const list = Array.isArray(tags) ? tags : []
list.forEach((t, idx) => {
const id = parseInt(t.id || '0', 10)
const name = String(t.name || '').trim()
const color = String(t.color || '').trim()
const sort_index = Number.isFinite(t.sort_index) ? t.sort_index : idx
if (!name) return
if (id && existingById.has(id)) {
updateTagStmt.run({ id, user_id: uid, name, color, sort_index })
keep.add(id)
} else {
const info = insertTagStmt.run({ user_id: uid, name, color, sort_index })
keep.add(info.lastInsertRowid)
}
})
existing.forEach(t => {
if (!keep.has(t.id)) {
deleteNoteTagsByTagStmt.run(t.id)
deleteTagStmt.run(t.id, uid)
}
})
})
tx()
}
const listNotesByTag = (userId, tagId) => {
const uid = parseInt(userId || '1', 10)
const tid = parseInt(tagId || '0', 10)
if (!tid) return []
return listNotesByTagStmt.all(uid, tid)
}
const dashboard = userId => {
const uid = parseInt(userId || '1', 10)
const notes = listNotesStmt.all(uid)
const now = new Date()
const todayStr = now.toISOString().slice(0, 10)
const weekAgoMs = now.getTime() - 6 * 24 * 3600 * 1000
let todayCount = 0
let weekCount = 0
let starredCount = 0
let pinnedCount = 0
let lastNoteId = 0
let lastNoteUpdated = 0
notes.forEach(n => {
const updated = String(n.updated_at || n.created_at || '')
if (updated.startsWith(todayStr)) todayCount++
const t = Date.parse(updated.replace(' ', 'T'))
if (!Number.isNaN(t) && t >= weekAgoMs) weekCount++
if (n.starred) starredCount++
if (n.pinned) pinnedCount++
if (!Number.isNaN(t) && t > lastNoteUpdated) {
lastNoteUpdated = t
lastNoteId = n.id
}
})
const recentNotes = notes.slice(0, 6)
const pinned = notes.filter(n => n.pinned).slice(0, 6)
const starred = notes.filter(n => n.starred).slice(0, 6)
const tagStats = tagStatsStmt.all(uid)
const hotTags = tagStats.slice(0, 8)
const untaggedRow = untaggedCountStmt.get(uid)
const untaggedCount = untaggedRow && typeof untaggedRow.c === 'number' ? untaggedRow.c : 0
const recentAttachments = recentAttachmentsByUserStmt.all(uid)
return {
overview: {
total_notes: notes.length,
today_count: todayCount,
week_count: weekCount,
starred_count: starredCount,
pinned_count: pinnedCount
},
recent_notes: recentNotes,
pinned_notes: pinned,
starred_notes: starred,
hot_tags: hotTags,
untagged_count: untaggedCount,
recent_attachments: recentAttachments,
last_note_id: lastNoteId
}
}
const hardDeleteNote = id => {
const nid = parseInt(id, 10)
if (!nid) return
const atts = listAttachmentsStmt.all(nid)
atts.forEach(a => {
try {
const rel = String(a.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const refs = countAttachmentByPathStmt.get(a.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
deleteAttachmentStmt.run(parseInt(a.id, 10))
})
hardDeleteNoteStmt.run({ id: nid })
deleteNoteTagsByNoteStmt.run(nid)
}
module.exports = { login, saveNote, listVersions, createAttachment, listAttachments, deleteAttachment, deleteAttachmentWithFile, listNotes, getNote, searchNotes, setStar, setPin, listTrashNotes, softDeleteNote, restoreNote, hardDeleteNote, listTags, saveTags, listNotesByTag, dashboard, cloneAttachments, getTinyToolbarSettings, saveTinyToolbarSettings }
+273
View File
@@ -0,0 +1,273 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'cloud_sheets.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
workbook_json TEXT NOT NULL,
preview_text TEXT DEFAULT '',
starred INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS sheet_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
title TEXT NOT NULL,
workbook_json TEXT NOT NULL,
preview_text TEXT DEFAULT '',
saved_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);`)
const getUserCount = db.prepare(`SELECT COUNT(1) AS c FROM users`)
const findUser = db.prepare(`SELECT * FROM users WHERE username=?`)
const createUser = db.prepare(`INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)`)
const insertSheetStmt = db.prepare(`INSERT INTO sheets (user_id, title, workbook_json, preview_text) VALUES (@user_id, @title, @workbook_json, @preview_text)`)
const updateSheetStmt = db.prepare(`UPDATE sheets SET title=@title, workbook_json=@workbook_json, preview_text=@preview_text, updated_at=datetime('now') WHERE id=@id`)
const insertVersionStmt = db.prepare(`INSERT INTO sheet_versions (sheet_id, title, workbook_json, preview_text) VALUES (@sheet_id, @title, @workbook_json, @preview_text)`)
const latestVersionStmt = db.prepare(`SELECT id, sheet_id, title, workbook_json, preview_text, datetime(saved_at, 'localtime') AS saved_at FROM sheet_versions WHERE sheet_id=? ORDER BY id DESC LIMIT 1`)
const listVersionsStmt = db.prepare(`SELECT id, sheet_id, title, workbook_json, preview_text, datetime(saved_at, 'localtime') AS saved_at FROM sheet_versions WHERE sheet_id=? ORDER BY id DESC LIMIT 50`)
const listSheetsStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`)
const listTrashSheetsStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, updated_at DESC`)
const getSheetStmt = db.prepare(`SELECT id, user_id, title, workbook_json, preview_text, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE id=?`)
const searchSheetsStmt = db.prepare(`SELECT id, user_id, title, workbook_json, preview_text, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NULL AND (title LIKE ? OR preview_text LIKE ?) ORDER BY updated_at DESC`)
const softDeleteSheetStmt = db.prepare(`UPDATE sheets SET deleted_at=datetime('now') WHERE id=@id`)
const restoreSheetStmt = db.prepare(`UPDATE sheets SET deleted_at=NULL WHERE id=@id`)
const hardDeleteSheetStmt = db.prepare(`DELETE FROM sheets WHERE id=@id`)
const setStarStmt = db.prepare(`UPDATE sheets SET starred=@starred WHERE id=@id`)
const setPinStmt = db.prepare(`UPDATE sheets SET pinned=@pinned WHERE id=@id`)
const createAttachmentStmt = db.prepare(`INSERT INTO attachments (sheet_id, filename, file_path, file_size) VALUES (@sheet_id, @filename, @file_path, @file_size)`)
const listAttachmentsStmt = db.prepare(`SELECT id, sheet_id, filename, file_path, file_size, datetime(created_at, 'localtime') AS created_at FROM attachments WHERE sheet_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, file_path FROM attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM attachments WHERE id=?`)
const deleteAttachmentsBySheetStmt = db.prepare(`DELETE FROM attachments WHERE sheet_id=?`)
const countAttachmentByPathStmt = db.prepare(`SELECT COUNT(1) AS c FROM attachments WHERE file_path=?`)
const hashPassword = (pwd, saltHex) => {
const salt = Buffer.from(saltHex, 'hex')
const key = crypto.scryptSync(String(pwd || ''), salt, 32)
return key.toString('hex')
}
const safeJsonStringify = input => {
try {
if (typeof input === 'string') {
const s = input.trim()
if (!s) return '{}'
try {
JSON.parse(s)
return s
} catch {
return JSON.stringify({ raw: s })
}
}
if (input && typeof input === 'object') return JSON.stringify(input)
} catch {}
return '{}'
}
const buildPreviewText = workbookJson => {
try {
const src = typeof workbookJson === 'string' ? workbookJson : JSON.stringify(workbookJson || {})
const raw = String(src || '')
.replace(/[{}[\]":,]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!raw) return ''
return raw.length <= 300 ? raw : `${raw.slice(0, 300)}...`
} catch {
return ''
}
}
const ensureAdmin = () => {
const c = getUserCount.get().c
if (c > 0) return
const salt = crypto.randomBytes(16).toString('hex')
const defaultPass = process.env.CLOUD_SHEETS_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const hash = hashPassword(defaultPass, salt)
createUser.run('admin_cs', hash, salt)
}
ensureAdmin()
const login = (username, password) => {
const u = findUser.get(username)
if (!u) {
const exists = getUserCount.get().c > 0
if (exists) return null
const salt = crypto.randomBytes(16).toString('hex')
const hash = hashPassword(password, salt)
createUser.run(username, hash, salt)
return findUser.get(username)
}
const ok = u.password_hash === hashPassword(password, u.salt)
return ok ? u : null
}
const saveSheet = payload => {
const id = parseInt(payload.id || '0', 10)
const user_id = parseInt(payload.user_id || '1', 10)
const title = String(payload.title || '未命名表格')
const workbook_json = safeJsonStringify(payload.workbook_json)
const preview_text = String(payload.preview_text || '').trim() || buildPreviewText(workbook_json)
const baseVersionId = parseInt(payload.base_version_id || '0', 10)
const force = !!payload.force
let sheetId = 0
let versionId = 0
if (!id) {
const info = insertSheetStmt.run({ user_id, title, workbook_json, preview_text })
const vinfo = insertVersionStmt.run({ sheet_id: info.lastInsertRowid, title, workbook_json, preview_text })
sheetId = info.lastInsertRowid
versionId = vinfo.lastInsertRowid
} else {
const latest = latestVersionStmt.get(id)
const latestId = latest && latest.id ? latest.id : 0
if (baseVersionId && latestId && baseVersionId !== latestId && !force) {
return { conflict: true, latest }
}
updateSheetStmt.run({ id, title, workbook_json, preview_text })
const vinfo = insertVersionStmt.run({ sheet_id: id, title, workbook_json, preview_text })
sheetId = id
versionId = vinfo.lastInsertRowid
}
return { sheet_id: sheetId, version_id: versionId, conflict: false }
}
const listSheets = userId => listSheetsStmt.all(userId)
const listTrashSheets = userId => listTrashSheetsStmt.all(userId)
const listVersions = sheetId => listVersionsStmt.all(sheetId)
const getSheet = id => {
const row = getSheetStmt.get(id)
if (!row) return null
const latest = latestVersionStmt.get(id)
row.version_id = latest && latest.id ? latest.id : 0
row.version_saved_at = latest && latest.saved_at ? latest.saved_at : ''
return row
}
const searchSheets = (userId, keyword) => {
const kw = String(keyword || '').trim()
if (!kw) return []
const pat = `%${kw}%`
return searchSheetsStmt.all(userId, pat, pat)
}
const setStar = payload => setStarStmt.run({ id: parseInt(payload.id, 10), starred: payload.starred ? 1 : 0 })
const setPin = payload => setPinStmt.run({ id: parseInt(payload.id, 10), pinned: payload.pinned ? 1 : 0 })
const softDeleteSheet = id => softDeleteSheetStmt.run({ id: parseInt(id, 10) })
const restoreSheet = id => restoreSheetStmt.run({ id: parseInt(id, 10) })
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
return info.lastInsertRowid
}
const listAttachments = sheetId => listAttachmentsStmt.all(sheetId)
const deleteAttachmentWithFile = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.file_path) {
try {
const refs = countAttachmentByPathStmt.get(row.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(row.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
}
deleteAttachmentStmt.run(aid)
}
const cloneAttachments = (srcSheetId, destSheetId) => {
const srcId = parseInt(srcSheetId, 10)
const dstId = parseInt(destSheetId, 10)
if (!srcId || !dstId || srcId === dstId) return
const atts = listAttachmentsStmt.all(srcId)
atts.forEach(a => {
createAttachmentStmt.run({
sheet_id: dstId,
filename: a.filename,
file_path: a.file_path,
file_size: a.file_size
})
})
}
const hardDeleteSheet = id => {
const sid = parseInt(id, 10)
if (!sid) return
const atts = listAttachmentsStmt.all(sid)
atts.forEach(a => {
try {
const refs = countAttachmentByPathStmt.get(a.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(a.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
})
deleteAttachmentsBySheetStmt.run(sid)
hardDeleteSheetStmt.run({ id: sid })
}
module.exports = {
login,
saveSheet,
listSheets,
listTrashSheets,
getSheet,
searchSheets,
listVersions,
setStar,
setPin,
softDeleteSheet,
restoreSheet,
hardDeleteSheet,
createAttachment,
listAttachments,
deleteAttachmentWithFile,
cloneAttachments
}
+85
View File
@@ -0,0 +1,85 @@
// ============================================================
// data_gateway/auth.js - 对外 API 鉴权模块
// 职责:校验 X-API-Id + X-API-Key,匹配 skill config.json 中的哈希
// ============================================================
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const SKILLS_DIR = path.join(__dirname, 'skills')
// 缓存已加载的 skill 配置
const skillConfigCache = new Map()
const loadSkillConfig = (skillId) => {
if (skillConfigCache.has(skillId)) return skillConfigCache.get(skillId)
const cfgPath = path.join(SKILLS_DIR, skillId, 'config.json')
if (!fs.existsSync(cfgPath)) return null
try {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
skillConfigCache.set(skillId, cfg)
return cfg
} catch {
return null
}
}
// 刷新缓存(用于配置热更新)
const refreshCache = (skillId) => {
skillConfigCache.delete(skillId)
return loadSkillConfig(skillId)
}
// 枚举所有已启用的 skill
const listEnabledSkills = () => {
const result = []
if (!fs.existsSync(SKILLS_DIR)) return result
const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const d of dirs) {
if (!d.isDirectory()) continue
const cfg = loadSkillConfig(d.name)
if (cfg && cfg.enabled !== false) {
result.push({ id: cfg.id, name: cfg.name })
}
}
return result
}
// 校验外部 API 请求
const verifyApiKey = (req) => {
const apiId = String(req.headers['x-api-id'] || '').trim()
const apiKey = String(req.headers['x-api-key'] || '').trim()
if (!apiId || !apiKey) return { ok: false, error: 'missing api id or key' }
const cfg = loadSkillConfig(apiId)
if (!cfg) return { ok: false, error: 'skill not found' }
if (cfg.enabled === false) return { ok: false, error: 'skill disabled' }
const expectedHash = String(cfg.api_key_hash || '')
if (!expectedHash) return { ok: false, error: 'skill not configured' }
const actualHash = 'sha256:' + crypto.createHash('sha256').update(apiKey).digest('hex')
if (actualHash !== expectedHash) return { ok: false, error: 'invalid api key' }
return { ok: true, skill: cfg }
}
// 校验内部 Tool 读取请求
const verifyInternalToken = (req) => {
const token = String(req.headers['x-internal-token'] || '').trim()
const skillId = String(req.headers['x-skill-id'] || req.params?.skillId || '').trim()
if (!token || !skillId) return { ok: false, error: 'missing token or skill id' }
const cfg = loadSkillConfig(skillId)
if (!cfg) return { ok: false, error: 'skill not found' }
const expectedHash = String(cfg.read_token_hash || '')
if (!expectedHash) return { ok: false, error: 'skill not configured for internal read' }
const actualHash = 'sha256:' + crypto.createHash('sha256').update(token).digest('hex')
if (actualHash !== expectedHash) return { ok: false, error: 'invalid internal token' }
return { ok: true, skill: cfg }
}
module.exports = { loadSkillConfig, refreshCache, listEnabledSkills, verifyApiKey, verifyInternalToken }
+219
View File
@@ -0,0 +1,219 @@
// ============================================================
// data_gateway/index.js - 对外 API 数据网关主入口
// 暴露两个路由:
// POST /api/v1/ingest/:skillId 外部数据写入(公网,需 API Key)
// GET /api/v1/data/:skillId/query 内部数据读取(仅 localhost,需 Internal Token)
// ============================================================
const { logJSON } = require('../logger')
const { verifyApiKey, verifyInternalToken, listEnabledSkills } = require('./auth')
const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count } = require('./store')
const { loadSkillConfig } = require('./auth')
const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage')
const { bindRoutes: bindEmailSenderRoutes } = require('./skills/email_sender')
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
// ============================================================
// 安全工具函数
// ============================================================
// 原型污染防护:移除 __proto__、constructor、prototype 等危险键
const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', '__proto__'])
const sanitizeObject = (obj, depth = 0) => {
if (depth > 10) return {} // 防止无限递归
if (obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) return obj.map(item => sanitizeObject(item, depth + 1))
const clean = {}
for (const [key, val] of Object.entries(obj)) {
if (PROTO_KEYS.has(key)) continue
clean[key] = sanitizeObject(val, depth + 1)
}
return clean
}
// 全局错误处理:隐藏详细堆栈,避免泄露路径和技术栈
const errorHandler = (err, req, res, _next) => {
try { logJSON('data_gateway.error', { path: req.path, error: String(err.message || err) }, 'data_gateway') } catch {}
if (!res.headersSent) {
res.status(500).json({ ok: false, error: 'internal_error' })
}
}
// Ingest 接口速率限制:同一 IP 每分钟最多 60 次请求
const ingestRateLimit = new Map()
const INGEST_MAX_PER_MIN = 60
const INGEST_WINDOW_MS = 60 * 1000
const checkIngestRate = (ip) => {
const rec = ingestRateLimit.get(ip)
if (!rec || Date.now() - rec.start > INGEST_WINDOW_MS) {
ingestRateLimit.set(ip, { start: Date.now(), count: 1 })
return { ok: true }
}
rec.count++
if (rec.count > INGEST_MAX_PER_MIN) {
const waitSec = Math.ceil((rec.start + INGEST_WINDOW_MS - Date.now()) / 1000)
return { ok: false, retryAfter: Math.max(1, waitSec) }
}
return { ok: true }
}
// 每分钟清理一次
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of ingestRateLimit.entries()) {
if (now - rec.start > INGEST_WINDOW_MS + 10000) ingestRateLimit.delete(ip)
}
}, 60000)
const SOURCE_MAX_LEN = 255
const DATA_MAX_BYTES = 10 * 1024 // 10KB
const bindRoutes = (app) => {
// ============================================================
// 外部存储 skill(第三方 OSS 读写)
// 路由挂载在 /api/v1/skills/external_storage/*
// ============================================================
try {
bindExternalStorageRoutes(app)
} catch (e) {
console.error(`[data_gateway] Failed to bind external_storage routes: ${e.message}`)
}
// ============================================================
// 邮件发送 skill(对外发邮件)
// 路由挂载在 /api/v1/ingest/email_sender/*
// ============================================================
try {
bindEmailSenderRoutes(app)
} catch (e) {
console.error(`[data_gateway] Failed to bind email_sender routes: ${e.message}`)
}
// ============================================================
// 外部写入接口(公网暴露)
// POST /api/v1/ingest/:skillId
// Headers: X-API-Id, X-API-Key
// Body: { "source": "...", "data": { ... } }
// ============================================================
app.post('/api/v1/ingest/:skillId', (req, res) => {
setNoCache(res)
const skillId = String(req.params.skillId || '').trim()
if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('data_gateway.auth.fail', { skillId, error: auth.error, ip: req.ip }, 'data_gateway') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkIngestRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '请求过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
const body = req.body || {}
let source = String(body.source || '').trim()
let data = body.data
// source 长度限制
if (source.length > SOURCE_MAX_LEN) {
return res.status(400).json({ ok: false, error: 'source 超过长度限制(' + SOURCE_MAX_LEN + ' 字符)' })
}
// 如果没有 data 字段,将整个 body 视为 data(排除 source)
if (data === undefined) {
const bodyData = { ...body }
delete bodyData.source
data = Object.keys(bodyData).length > 0 ? bodyData : body
}
// data 类型验证:必须是对象
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return res.status(400).json({ ok: false, error: 'data 必须是 JSON 对象' })
}
// data 大小限制
const dataBytes = Buffer.byteLength(JSON.stringify(data), 'utf8')
if (dataBytes > DATA_MAX_BYTES) {
return res.status(413).json({ ok: false, error: 'data 超过大小限制(' + Math.round(DATA_MAX_BYTES / 1024) + 'KB)' })
}
// 原型污染防护
data = sanitizeObject(data)
const record = insert(skillId, { source, data })
try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {}
return res.json({ ok: true, id: record.id, created_at: record.created_at })
})
// ============================================================
// 内部读取接口(仅 localhost)
// GET /api/v1/data/:skillId/query?mode=latest|list&limit=100&offset=0
// Headers: X-Internal-Token, X-Skill-Id
// ============================================================
app.get('/api/v1/data/:skillId/query', (req, res) => {
setNoCache(res)
const skillId = String(req.params.skillId || '').trim()
if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
const auth = verifyInternalToken(req)
if (!auth.ok) return res.status(401).json({ ok: false, error: auth.error })
const mode = String(req.query.mode || 'latest').trim()
const limit = parseInt(req.query.limit, 10) || 100
const offset = parseInt(req.query.offset, 10) || 0
const source = req.query.source || null
const start = req.query.start || null
const end = req.query.end || null
try {
if (mode === 'list') {
if (start || end) {
const result = queryByTimeRange(skillId, { start, end, limit })
return res.json({ ok: true, ...result })
}
const result = queryList(skillId, { limit, offset, source })
return res.json({ ok: true, ...result })
}
// mode === 'latest'(默认)
const row = queryLatest(skillId)
if (!row) return res.json({ ok: true, rows: [], total: 0 })
return res.json({ ok: true, rows: [row], total: 1 })
} catch (e) {
try { logJSON('data_gateway.query.error', { skillId, error: String(e.message || e) }, 'data_gateway') } catch {}
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// ============================================================
// 管理接口:列出所有已启用的 skill(仅 localhost)
// GET /api/v1/data/skills
// ============================================================
app.get('/api/v1/data/skills', (req, res) => {
setNoCache(res)
const skills = listEnabledSkills()
const result = skills.map(s => {
const cfg = loadSkillConfig(s.id)
return {
id: s.id,
name: s.name,
mode: String(cfg && cfg.mode || 'append'),
record_count: count(s.id)
}
})
res.json({ ok: true, skills: result })
})
// 全局错误处理:统一返回简洁错误,隐藏堆栈和路径信息
app.use(errorHandler)
}
module.exports = { bindRoutes, listEnabledSkills, loadSkillConfig }
@@ -0,0 +1,29 @@
{
"_comment": "邮件发送 skill - 对外邮件发送 API",
"id": "email_sender",
"name": "邮件发送",
"enabled": true,
"api_key_comment": "对外发送邮件的 API Key,妥善保管",
"api_key": "es_hAtXkry70lHtCVMfr0TaQ",
"api_key_hash": "sha256:88aee9d336c64b3b9bd86ab97e635604137c5de971b10419bb1801c7bd4eb7b9",
"smtp_comment": "SMTP 服务配置(优先级:环境变量 > 此处配置",
"smtp": {
"host_comment": "SMTP 服务器主机名",
"host": "smtp.gmail.com",
"port_comment": "SMTP 服务器端口号",
"port": 465,
"secure_comment": "是否使用 TLS/SSL 加密连接",
"secure": true,
"auth": {
"user_comment": "SMTP 登录用户名(建议通过环境变量 EMAIL_SENDER_SMTP_USER 配置)",
"user": "yangdafe@gmail.com",
"pass_comment": "SMTP 登录密码或应用专用密码(请通过环境变量 EMAIL_SENDER_SMTP_PASS 配置)",
"pass": ""
}
},
"from_comment": "发件人邮箱地址",
"from": "yangdafe@gmail.com",
"default_to_comment": "默认收件人(当请求参数中未提供时使用)",
"default_to": "yangxiangyuan@umer.com.cn",
"env_mapping_comment": "环境变量映射(优先级最高)\n EMAIL_SENDER_SMTP_HOST\n EMAIL_SENDER_SMTP_PORT\n EMAIL_SENDER_SMTP_SECURE (true/false)\n EMAIL_SENDER_SMTP_USER\n EMAIL_SENDER_SMTP_PASS\n EMAIL_SENDER_FROM\n EMAIL_SENDER_DEFAULT_TO"
}
@@ -0,0 +1,180 @@
const path = require('path')
const Database = require('better-sqlite3')
const SKILL_DIR = __dirname
const DB_PATH = path.join(SKILL_DIR, 'email.db')
let db = null
/**
* 脱敏 SMTP 响应信息,过滤文件路径、堆栈等敏感内容
*/
const sanitizeSmtpResponse = (text) => {
if (!text || typeof text !== 'string') return null
let result = String(text)
// 过滤堆栈跟踪(先做,避免路径被部分替换后残留)
result = result.replace(/at\s+\(?.*?\)?(\n|$)/g, '')
// 过滤 Windows 文件路径 D:\xxx 或 C:\xxx
result = result.replace(/[A-Z]:\\(?:[^\\]|\\.)*/gi, '[path]')
// 过滤 Unix 长路径
result = result.replace(/(?:^|\s)\/[\w.\-\/]{8,}/g, (m) => m.replace(/\/[\w.\-\/]{8,}/, ' [path]'))
return result.trim() || null
}
/**
* 获取或创建独立的 SQLite 数据库连接
* 不共享 data_gateway/store.js,完全独立
*/
const getDB = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('foreign_keys = ON')
initSchema(db)
return db
}
/**
* 初始化数据库表结构
*/
const initSchema = (database) => {
database.exec(`
CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_hash TEXT NOT NULL,
to_addr TEXT NOT NULL,
subject TEXT NOT NULL,
text_preview TEXT,
html_preview TEXT,
attachments TEXT,
status TEXT NOT NULL CHECK(status IN ('sent', 'failed')),
smtp_response TEXT,
created_at TEXT NOT NULL
)
`)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_email_logs_created_at ON email_logs(created_at DESC)
`)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_email_logs_status ON email_logs(status)
`)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_email_logs_to_addr ON email_logs(to_addr)
`)
}
/**
* 插入邮件发送日志
*/
const insertLog = (database, data) => {
const stmt = database.prepare(`
INSERT INTO email_logs (api_key_hash, to_addr, subject, text_preview, html_preview, attachments, status, smtp_response, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
data.apiKeyHash,
data.to,
data.subject,
data.textPreview,
data.htmlPreview,
data.attachments || null,
data.status,
data.smtpResponse || null,
data.createdAt
)
return result.lastInsertRowid
}
/**
* 查询日志列表(支持分页和筛选)
*/
const queryLogs = (database, filters) => {
const { limit = 20, offset = 0, status, toAddr, fromDate, toDate, apiKeyHash } = filters
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100)
const safeOffset = Math.max(Number(offset) || 0, 0)
let whereClauses = ['1=1']
const params = []
if (apiKeyHash) {
whereClauses.push('api_key_hash = ?')
params.push(apiKeyHash)
}
if (status) {
whereClauses.push('status = ?')
params.push(status)
}
if (toAddr) {
whereClauses.push('to_addr = ?')
params.push(toAddr)
}
if (fromDate) {
whereClauses.push('created_at >= ?')
params.push(`${fromDate}T00:00:00.000Z`)
}
if (toDate) {
whereClauses.push('created_at <= ?')
params.push(`${toDate}T23:59:59.999Z`)
}
const whereSQL = whereClauses.join(' AND ')
// Count total
const countStmt = database.prepare(`SELECT COUNT(*) as total FROM email_logs WHERE ${whereSQL}`)
const { total } = countStmt.get(...params)
// Query with pagination
const queryStmt = database.prepare(`
SELECT id, api_key_hash, to_addr, subject, text_preview, html_preview, attachments, status, smtp_response, created_at
FROM email_logs
WHERE ${whereSQL}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
const logs = queryStmt.all(...params, safeLimit, safeOffset)
return {
logs: logs.map(l => ({
id: l.id,
to: l.to_addr,
subject: l.subject,
text_preview: l.text_preview,
html_preview: l.html_preview,
attachments: l.attachments ? JSON.parse(l.attachments) : null,
status: l.status,
smtp_response: sanitizeSmtpResponse(l.smtp_response),
created_at: l.created_at
})),
total,
limit: safeLimit,
offset: safeOffset
}
}
/**
* 查询单条日志详情
*/
const getLogById = (database, id, apiKeyHash) => {
const stmt = database.prepare(`
SELECT id, api_key_hash, to_addr, subject, text_preview, html_preview, attachments, status, smtp_response, created_at
FROM email_logs
WHERE id = ? AND api_key_hash = ?
`)
const row = stmt.get(id, apiKeyHash)
if (!row) return null
return {
id: row.id,
to: row.to_addr,
subject: row.subject,
text_preview: row.text_preview,
html_preview: row.html_preview,
attachments: row.attachments ? JSON.parse(row.attachments) : null,
status: row.status,
smtp_response: sanitizeSmtpResponse(row.smtp_response),
created_at: row.created_at
}
}
module.exports = { getDB, insertLog, queryLogs, getLogById }
Binary file not shown.
@@ -0,0 +1,269 @@
const express = require('express')
const crypto = require('crypto')
const { verifyApiKey } = require('../../auth')
const { sendEmail, loadConfig } = require('./mail_client')
const { getDB, insertLog, queryLogs, getLogById } = require('./db')
const { logJSON } = require('../../../logger')
// ============================================================
// Email Sender Skill — 对外邮件发送 API
// 路由挂载在 /api/v1/ingest/email_sender/*
// POST /send
// GET /logs
// GET /logs/:id
// ============================================================
const SKILL_ID = 'email_sender'
const RATE_LIMIT_MAX = 30 // 每分钟 30 封
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const MAX_SUBJECT_LENGTH = 200
const MAX_TEXT_LENGTH = 50000 // 50KB
const MAX_HTML_LENGTH = 100000 // 100KB
const MAX_ATTACHMENTS = 3
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PREVIEW_LENGTH = 500
// 速率限制存储
const rateLimitMap = new Map()
const checkRate = (ip) => {
const rec = rateLimitMap.get(ip)
if (!rec || Date.now() - rec.start > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(ip, { start: Date.now(), count: 1 })
return { ok: true }
}
rec.count++
if (rec.count > RATE_LIMIT_MAX) {
const waitSec = Math.ceil((rec.start + RATE_LIMIT_WINDOW_MS - Date.now()) / 1000)
return { ok: false, retryAfter: Math.max(1, waitSec) }
}
return { ok: true }
}
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of rateLimitMap.entries()) {
if (now - rec.start > RATE_LIMIT_WINDOW_MS + 10000) rateLimitMap.delete(ip)
}
}, 60000)
/**
* 邮箱格式校验
*/
const isValidEmail = (email) => {
if (typeof email !== 'string' || !email) return false
return EMAIL_REGEX.test(email.trim())
}
/**
* 内容预览截取
*/
const makePreview = (text, maxLength) => {
if (!text) return null
const str = String(text)
if (str.length <= maxLength) return str
return str.slice(0, maxLength) + '...'
}
/**
* 绑定路由到 Express 应用
*/
const bindRoutes = (app) => {
const router = express.Router()
// API Key 鉴权 + skill ID 校验
router.use((req, res, next) => {
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('email_sender.auth.fail', { error: auth.error, ip: req.ip }, 'email_sender') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
const apiId = String(req.headers['x-api-id'] || '').trim()
if (apiId !== SKILL_ID) {
return res.status(403).json({ ok: false, error: 'skill id mismatch' })
}
next()
})
// 无缓存
router.use((req, res, next) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
next()
})
// ============================================================
// 发送邮件
// POST /api/v1/ingest/email_sender/send
// ============================================================
router.post('/send', async (req, res) => {
try {
const body = req.body || {}
// 解析收件人
let to = body.to
const cfg = loadConfig()
if (!to || String(to).trim() === '') {
to = cfg.defaultTo
} else {
to = String(to).trim()
}
// CRLF 注入防护:清除换行符
to = to.replace(/[\r\n]/g, '')
// 校验
if (!isValidEmail(to)) {
return res.status(400).json({ ok: false, error: '收件人邮箱格式无效' })
}
const subject = body.subject
if (!subject || typeof subject !== 'string' || subject.trim() === '') {
return res.status(400).json({ ok: false, error: '邮件主题不能为空' })
}
// CRLF 注入防护:清除换行符
const cleanSubject = String(subject).replace(/[\r\n]/g, '')
if (cleanSubject.length > MAX_SUBJECT_LENGTH) {
return res.status(400).json({ ok: false, error: `主题不能超过 ${MAX_SUBJECT_LENGTH} 字符` })
}
const text = body.text !== undefined ? body.text : null
const html = body.html !== undefined ? body.html : null
if ((text === null || text === '') && (html === null || html === '')) {
return res.status(400).json({ ok: false, error: 'text 和 html 内容至少需要提供一个' })
}
if (text !== null && typeof text === 'string' && text.length > MAX_TEXT_LENGTH) {
return res.status(400).json({ ok: false, error: `纯文本内容不能超过 ${MAX_TEXT_LENGTH} 字符` })
}
if (html !== null && typeof html === 'string' && html.length > MAX_HTML_LENGTH) {
return res.status(400).json({ ok: false, error: `HTML 内容不能超过 ${MAX_HTML_LENGTH} 字符` })
}
const attachments = body.attachments || []
if (!Array.isArray(attachments)) {
return res.status(400).json({ ok: false, error: 'attachments 必须是数组' })
}
if (attachments.length > MAX_ATTACHMENTS) {
return res.status(400).json({ ok: false, error: `附件数量不能超过 ${MAX_ATTACHMENTS} 个` })
}
for (const att of attachments) {
if (!att.filename || !att.path) {
return res.status(400).json({ ok: false, error: '每个附件必须包含 filename 和 path' })
}
if (typeof att.filename !== 'string' || typeof att.path !== 'string') {
return res.status(400).json({ ok: false, error: '附件 filename 和 path 必须是字符串' })
}
if (att.path.includes('..')) {
return res.status(400).json({ ok: false, error: '附件路径不允许包含 ".."' })
}
}
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '发送过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
// 发送邮件
const result = await sendEmail({
to,
subject: cleanSubject.trim(),
text: text ? String(text) : undefined,
html: html ? String(html) : undefined,
attachments: attachments.length > 0 ? attachments : undefined
})
// 记录日志
const database = getDB()
const apiKeyHash = crypto.createHash('sha256').update(String(req.headers['x-api-key'] || '')).digest('hex')
const emailId = insertLog(database, {
apiKeyHash,
to,
subject: cleanSubject.trim(),
textPreview: makePreview(text, PREVIEW_LENGTH),
htmlPreview: makePreview(html, PREVIEW_LENGTH),
attachments: attachments.length > 0 ? JSON.stringify(attachments.map(a => ({ filename: a.filename }))) : null,
status: result.ok ? 'sent' : 'failed',
smtpResponse: result.response || result.error || null,
createdAt: new Date().toISOString()
})
if (result.ok) {
return res.json({
ok: true,
email_id: emailId,
accepted: result.accepted,
rejected: result.rejected
})
} else {
return res.status(500).json({
ok: false,
error: result.error,
email_id: emailId
})
}
} catch (e) {
try { logJSON('email_sender.send.error', { error: String(e.message || e) }, 'email_sender') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 查询发送日志列表
// GET /api/v1/ingest/email_sender/logs?limit=20&offset=0&status=sent&to=xxx&from_date=2026-01-01&to_date=2026-06-30
// ============================================================
router.get('/logs', async (req, res) => {
try {
const apiKeyHash = crypto.createHash('sha256').update(String(req.headers['x-api-key'] || '')).digest('hex')
const filters = {
limit: req.query.limit,
offset: req.query.offset,
status: req.query.status || undefined,
toAddr: req.query.to || undefined,
fromDate: req.query.from_date || undefined,
toDate: req.query.to_date || undefined,
apiKeyHash
}
const result = queryLogs(getDB(), filters)
res.json({ ok: true, ...result })
} catch (e) {
try { logJSON('email_sender.logs.error', { error: String(e.message || e) }, 'email_sender') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 查询单条日志详情
// GET /api/v1/ingest/email_sender/logs/:id
// ============================================================
router.get('/logs/:id', async (req, res) => {
try {
const id = Number(req.params.id)
if (!id || isNaN(id)) {
return res.status(400).json({ ok: false, error: '无效的日志 ID' })
}
const apiKeyHash = crypto.createHash('sha256').update(String(req.headers['x-api-key'] || '')).digest('hex')
const log = getLogById(getDB(), id, apiKeyHash)
if (!log) {
return res.status(404).json({ ok: false, error: '日志不存在或无权访问' })
}
res.json({ ok: true, log })
} catch (e) {
try { logJSON('email_sender.log_detail.error', { error: String(e.message || e) }, 'email_sender') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// 挂载路由
app.use('/api/v1/ingest/email_sender', router)
}
module.exports = { bindRoutes }
@@ -0,0 +1,133 @@
const nodemailer = require('nodemailer')
const fs = require('fs')
const path = require('path')
/**
* 从配置文件 + 环境变量加载 SMTP 配置
* config.json 只存空模板,实际凭证从 process.env 读取
* 优先级:process.env > config.json 空值
*/
const loadConfig = () => {
const configPath = path.join(__dirname, 'config.json')
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
// 从环境变量覆盖敏感字段
const smtpHost = process.env.EMAIL_SENDER_SMTP_HOST || config.smtp?.host || ''
const smtpPort = Number(process.env.EMAIL_SENDER_SMTP_PORT || config.smtp?.port || 0)
const smtpSecure = process.env.EMAIL_SENDER_SMTP_SECURE !== 'false'
const smtpUser = process.env.EMAIL_SENDER_SMTP_USER || config.smtp?.auth?.user || ''
const smtpPass = process.env.EMAIL_SENDER_SMTP_PASS || config.smtp?.auth?.pass || ''
const from = process.env.EMAIL_SENDER_FROM || config.from || ''
const defaultTo = process.env.EMAIL_SENDER_DEFAULT_TO || config.default_to || 'yangxiangyuan@umer.com.cn'
return {
smtp: {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass }
},
from,
defaultTo
}
}
/**
* 创建 nodemailer transporter
* 每次调用时新建,避免复用失败的连接
*/
const createTransporter = (config) => {
return nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth: {
user: config.smtp.auth.user,
pass: config.smtp.auth.pass
},
// 连接超时
connectionTimeout: 10000,
// 发送邮件超时
socketTimeout: 30000,
// Greeting 超时
greetingTimeout: 10000
})
}
/**
* 发送邮件
* @param {Object} params
* @param {string} params.to - 收件人
* @param {string} params.subject - 主题
* @param {string} [params.text] - 纯文本内容
* @param {string} [params.html] - HTML 内容
* @param {Array} [params.attachments] - 附件数组 [{ filename, path }]
* @returns {Promise<{ ok: boolean, accepted: string[], rejected: string[], response: string, error: string }>}
*/
const sendEmail = async (params) => {
const config = loadConfig()
// 验证配置完整性
if (!config.smtp.host || !config.smtp.port || !config.smtp.auth.user || !config.smtp.auth.pass) {
return { ok: false, error: 'SMTP 配置不完整' }
}
if (!config.from) {
return { ok: false, error: '发件人地址未配置' }
}
const transporter = createTransporter(config)
try {
// 验证 SMTP 连接
await transporter.verify()
const mailOptions = {
from: config.from,
to: params.to,
subject: params.subject,
text: params.text || undefined,
html: params.html || undefined,
attachments: params.attachments || undefined
}
const info = await transporter.sendMail(mailOptions)
return {
ok: true,
accepted: info.accepted || [],
rejected: info.rejected || [],
response: String(info.response || '')
}
} catch (e) {
// 通用化错误信息,不暴露 SMTP 凭证
const msg = String(e.message || e)
let userError = '发送失败'
if (msg.includes('ENOTFOUND') || msg.includes('getaddrinfo')) {
userError = 'SMTP 服务器地址无法解析'
} else if (msg.includes('ECONNREFUSED')) {
userError = 'SMTP 服务器连接被拒绝'
} else if (msg.includes('ETIMEDOUT') || msg.includes('Timeout')) {
userError = 'SMTP 连接超时'
} else if (msg.includes('Authentication') || msg.includes('AUTH')) {
userError = 'SMTP 认证失败'
} else if (msg.includes('recipient')) {
userError = '收件人地址被拒绝'
} else if (msg.includes('550') || msg.includes('553')) {
userError = '邮件被 SMTP 服务器拒绝'
}
return {
ok: false,
error: userError,
accepted: [],
rejected: [],
response: msg
}
} finally {
// 关闭连接
try { transporter.close() } catch {}
}
}
module.exports = { loadConfig, sendEmail }
@@ -0,0 +1,14 @@
{
"_comment": "外部存储 skill - 供第三方程序读写 OSS external_storage 区域",
"id": "external_storage",
"name": "外部存储",
"enabled": true,
"api_key_comment": "这个发给对方,API Key,用于上传文件到外部存储区域",
"api_key": "4a211084d059Pk0C6t8dlk3ew61d46",
"api_key_hash": "sha256:17eb4c895064b955d80d007b15af992d7bb7bd1525ce5726f2f63f06229857c9",
"oss_bucket": "android-o1-images",
"oss_endpoint_backup1": "oss-accelerate.aliyuncs.com 传输加速域名(全地域上传下载加速)",
"oss_endpoint_backup2": "oss-cn-shanghai.aliyuncs.com 上海区域",
"oss_endpoint": "oss-accelerate.aliyuncs.com",
"oss_prefix": "550e8400-e29b-41d4-a716-446655442082/external_storage"
}
@@ -0,0 +1,380 @@
// ============================================================
// external_storage/index.js - 外部存储 skill 路由入口
// 提供第三方程序读写 OSS external_storage 区域的 API
// ============================================================
const express = require('express')
const multer = require('multer')
const path = require('path')
const archiver = require('archiver')
const { createOssClient } = require('./oss_client')
const { sanitizePath, createSizeLimitMiddleware } = require('./middleware')
const { loadSkillConfig } = require('../../auth')
const { verifyApiKey } = require('../../auth')
const { logJSON } = require('../../../logger')
const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 // 4GB
const fs = require('fs')
const os = require('os')
// 确保临时目录存在
const TEMP_DIR = path.join(os.tmpdir(), 'external_storage_uploads')
if (!fs.existsSync(TEMP_DIR)) {
try { fs.mkdirSync(TEMP_DIR, { recursive: true }) } catch (e) {}
}
// 允许上传的文件扩展名白名单(禁止 .html/.js/.exe/.php 等可执行/脚本文件)
const ALLOWED_EXTENSIONS = new Set([
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico', // 图片
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', // 文档
'txt', 'csv', 'log', 'json', 'xml', 'yaml', 'yml', // 文本
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', // 压缩
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', // 音视频
])
const validateFileExtension = (filename) => {
const ext = path.extname(filename).replace(/^\./, '').toLowerCase()
if (!ext) return false // 无扩展名
return ALLOWED_EXTENSIONS.has(ext)
}
// 磁盘临时存储上传文件
const upload = multer({
dest: TEMP_DIR,
limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 }
})
// MIME 类型推断(支持中文文件名 UTF-8)
const contentTypeByExt = (filename) => {
const ext = path.extname(filename).toLowerCase().slice(1)
const map = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', avif: 'image/avif',
txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8',
js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json',
xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv',
pdf: 'application/pdf',
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska',
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed',
tar: 'application/x-tar', gz: 'application/gzip',
exe: 'application/x-msdownload', dmg: 'application/x-apple-diskimage',
font: 'font/woff', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf'
}
return map[ext] || 'application/octet-stream'
}
// 安全文件名清理(保留中文,移除危险字符)
const safeName = (name) => {
let s = String(name || 'untitled')
// 尝试修复编码(UTF-8 被误读为 latin1 的情况)
try {
const recovered = Buffer.from(s, 'latin1').toString('utf8')
if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
} catch {}
// 移除危险字符,但保留中文、英文、数字、常见符号
return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'untitled'
}
// 设置无缓存响应头
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
/**
* 绑定路由到 Express 应用
* @param {express.Application} app
*/
const bindRoutes = (app) => {
const skillId = 'external_storage'
const skillConfig = loadSkillConfig(skillId)
if (!skillConfig) {
console.warn(`[external_storage] config.json not found, skill disabled`)
return
}
let ossClient
try {
ossClient = createOssClient(skillConfig)
} catch (e) {
console.error(`[external_storage] Failed to init OSS client: ${e.message}`)
return
}
const router = express.Router()
// API Key 鉴权中间件 — 强制 X-API-Id 必须为 external_storage,防止跨 skill 鉴权泄露
router.use((req, res, next) => {
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('external_storage.auth.fail', { error: auth.error, ip: req.ip }, 'external_storage') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
// 校验 skill ID 必须匹配
const apiId = String(req.headers['x-api-id'] || '').trim()
if (apiId !== 'external_storage') {
return res.status(403).json({ ok: false, error: 'skill id mismatch' })
}
next()
})
// 全局无缓存
router.use((req, res, next) => {
setNoCache(res)
next()
})
// ============================================================
// 上传文件
// POST /api/v1/skills/external_storage/upload?path=subdir/
// Headers: X-API-Id, X-API-Key
// Body: multipart/form-data, field: "files"
// ============================================================
router.post('/upload', createSizeLimitMiddleware(MAX_UPLOAD_BYTES), upload.array('files', 20), async (req, res) => {
try {
const basePath = sanitizePath(req.query.path || '')
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ ok: false, error: '未收到文件' })
}
const results = []
const rejected = []
const tempFiles = [] // 记录临时文件路径,最后统一删除
for (const f of req.files) {
if (f.path) tempFiles.push(f.path)
const originalName = safeName(f.originalname || 'file')
// 文件扩展名白名单校验
if (!validateFileExtension(originalName)) {
rejected.push({ name: originalName, reason: 'unsupported_extension' })
continue
}
const keyPath = basePath ? `${basePath}/${originalName}` : originalName
const contentType = f.mimetype || contentTypeByExt(originalName)
const { key, raw_url } = await ossClient.putObject(keyPath, f.path || f.buffer, contentType)
results.push({
path: keyPath,
oss_key: key,
raw_url,
name: originalName,
size: f.size,
content_type: contentType
})
}
// 删除临时文件
for (const fp of tempFiles) {
try { fs.unlinkSync(fp) } catch (e) {}
}
if (rejected.length > 0 && results.length === 0) {
return res.status(400).json({ ok: false, error: '所有文件的扩展名均不被允许', rejected })
}
res.json({ ok: true, count: results.length, files: results, rejected: rejected.length > 0 ? rejected : undefined })
} catch (e) {
// 不管成功失败,都删除临时文件
if (req.files && Array.isArray(req.files)) {
for (const f of req.files) {
if (f.path) try { fs.unlinkSync(f.path) } catch (ee) {}
}
}
if (String(e.message || '').includes('File too large')) {
return res.status(413).json({ ok: false, error: '文件超过 4GB 上限' })
}
try { logJSON('external_storage.upload.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '上传失败' })
}
})
// ============================================================
// 下载文件(代理流)
// GET /api/v1/skills/external_storage/download?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/download', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const displayName = path.basename(filePath)
const { stream, headers } = await ossClient.getObjectStream(filePath)
// 使用 RFC 5987 格式支持 UTF-8 文件名
res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`)
if (headers['content-type']) res.set('Content-Type', headers['content-type'])
if (headers['content-length']) res.set('Content-Length', headers['content-length'])
stream.pipe(res)
} catch (e) {
try { logJSON('external_storage.download.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '下载失败' })
}
})
// ============================================================
// 获取原始 URL
// GET /api/v1/skills/external_storage/raw_url?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/raw_url', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const raw_url = ossClient.getRawUrl(filePath)
res.json({ ok: true, path: filePath, raw_url })
} catch (e) {
try { logJSON('external_storage.raw_url.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 列出文件
// GET /api/v1/skills/external_storage/list?prefix=subdir/&recursive=true|false
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/list', async (req, res) => {
try {
const prefix = sanitizePath(req.query.prefix || '')
const recursive = req.query.recursive === 'true'
let result
if (recursive) {
const files = await ossClient.listRecursive(prefix)
result = { files, folders: [] }
} else {
result = await ossClient.listObjects(prefix)
}
res.json({ ok: true, prefix, recursive, ...result })
} catch (e) {
try { logJSON('external_storage.list.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 删除文件
// DELETE /api/v1/skills/external_storage/delete?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.delete('/delete', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
await ossClient.deleteObject(filePath)
res.json({ ok: true, path: filePath })
} catch (e) {
try { logJSON('external_storage.delete.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '删除失败' })
}
})
// ============================================================
// 文件夹打包下载
// GET /api/v1/skills/external_storage/folder/download?path=subdir/
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/folder/download', async (req, res) => {
try {
const folderPath = sanitizePath(req.query.path)
if (!folderPath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const folderKey = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const folderName = path.basename(folderKey.replace(/\/+$/, '')) || 'folder'
const files = await ossClient.listRecursive(folderKey)
res.set('Content-Type', 'application/zip')
res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(folderName + '.zip')}`)
const archive = archiver('zip', { zlib: { level: 6 } })
archive.on('error', err => {
try { res.status(500).end() } catch {}
})
archive.pipe(res)
const prefixClean = folderKey.replace(/\/+$/, '') + '/'
for (const f of files) {
try {
// 提取相对于文件夹前缀的路径
const fullRel = f.key
const relPart = fullRel.slice(prefixClean.length)
const { stream } = await ossClient.getObjectStream(relPart)
archive.append(stream, { name: relPart })
} catch {}
}
archive.finalize()
} catch (e) {
try { logJSON('external_storage.folder_download.error', { error: String(e.message || e) }, 'external_storage') } catch {}
if (!res.headersSent) res.status(500).json({ ok: false, error: '打包下载失败' })
}
})
// ============================================================
// 获取文件信息
// GET /api/v1/skills/external_storage/info?path=xxx
// Headers: X-API-Id, X-API-Key
// ============================================================
router.get('/info', async (req, res) => {
try {
const filePath = sanitizePath(req.query.path)
if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
const isFolder = filePath.endsWith('/')
const fullKey = ossClient.fullKey(filePath)
const raw_url = ossClient.getRawUrl(filePath)
let size = 0
let lastModified = 0
if (!isFolder) {
try {
const parent = filePath.includes('/') ? filePath.split('/').slice(0, -1).join('/') : ''
const result = await ossClient.listObjects(parent)
const targetKey = fullKey
const hit = result.files.find(f => f.key === targetKey)
if (hit) {
size = hit.size
lastModified = hit.lastModified
}
} catch {}
} else {
try {
const files = await ossClient.listRecursive(filePath)
size = files.reduce((s, f) => s + (Number(f.size) || 0), 0)
} catch {}
}
res.json({
ok: true,
info: {
path: filePath,
oss_key: fullKey,
raw_url,
isFolder,
size,
lastModified: lastModified ? new Date(lastModified).toISOString() : null
}
})
} catch (e) {
try { logJSON('external_storage.info.error', { error: String(e.message || e) }, 'external_storage') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// 挂载路由
app.use('/api/v1/skills/external_storage', router)
console.log('[external_storage] Routes mounted at /api/v1/skills/external_storage')
}
module.exports = { bindRoutes }
@@ -0,0 +1,92 @@
// ============================================================
// external_storage/middleware.js - 路径安全校验中间件
// 职责:防止路径遍历攻击,确保所有操作限定在 external_storage 范围内
// ============================================================
/**
* 校验并清理路径
* - 拒绝包含 ../ 的路径遍历
* - 拒绝绝对路径
* - 拒绝控制字符
* - 统一使用 UTF-8 编码,正确处理中文
* @param {string} rawPath - 原始路径参数
* @returns {string} 清理后的安全路径
* @throws {Error} 如果路径不合法
*/
const sanitizePath = (rawPath) => {
if (rawPath === undefined || rawPath === null) return ''
// 确保使用 UTF-8 字符串
let p = String(rawPath)
// 拒绝路径遍历(在规范化之前检查原始字符串中的 ..)
if (p.includes('..')) {
throw new Error('路径不允许包含 ".."')
}
// Unicode 规范化:NFKC 将全角/兼容字符转为标准形式
// 如 \uff0e (全角点 .) → . (ASCII 点)
p = p.normalize('NFKC')
// 规范化后再次检查路径遍历(防止 Unicode 全角字符绕过)
if (p.includes('..')) {
throw new Error('路径不允许包含 ".."')
}
// 拒绝控制字符(除了正常的换行和制表符)
if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(p)) {
throw new Error('路径包含非法控制字符')
}
// 拒绝绝对路径
if (p.startsWith('/') || p.startsWith('\\')) {
throw new Error('路径不允许以 "/" 或 "\\" 开头')
}
// 统一斜杠
p = p.replace(/[\\]+/g, '/')
// 去除连续斜杠
p = p.replace(/\/+/g, '/')
// 去除首尾斜杠
p = p.replace(/^\/+|\/+$/g, '')
return p
}
/**
* 校验路径是否在允许的前缀范围内
* @param {string} ossKey - 完整 OSS key
* @param {string} allowedPrefix - 允许的前缀
* @returns {boolean}
*/
const isPathWithinPrefix = (ossKey, allowedPrefix) => {
const key = String(ossKey || '')
const prefix = String(allowedPrefix || '').replace(/\/+$/, '')
return key === prefix || key.startsWith(prefix + '/')
}
/**
* 文件大小校验中间件
* @param {number} maxBytes - 最大字节数
* @returns {Function} express 中间件
*/
const createSizeLimitMiddleware = (maxBytes = 50 * 1024 * 1024) => {
return (req, res, next) => {
const contentLength = parseInt(req.headers['content-length'] || '0', 10)
if (contentLength > maxBytes) {
return res.status(413).json({
ok: false,
error: `文件超过大小限制 (${Math.round(maxBytes / 1024 / 1024)}MB)`
})
}
next()
}
}
module.exports = {
sanitizePath,
isPathWithinPrefix,
createSizeLimitMiddleware
}
@@ -0,0 +1,345 @@
// ============================================================
// external_storage/oss_client.js - 阿里云 OSS 客户端封装
// 职责:提供上传、下载、删除、列表、获取URL等基础操作
// 所有操作限定在 config.json 配置的 oss_prefix 范围内
// ============================================================
const crypto = require('crypto')
const https = require('https')
const http = require('http')
const fs = require('fs')
const path = require('path')
const { URLSearchParams } = require('url')
/**
* 初始化 OSS 客户端配置
* @param {Object} skillConfig - skill 的 config.json 内容
* @returns {Object} 客户端实例
*/
const createOssClient = (skillConfig) => {
const bucket = skillConfig.oss_bucket || 'android-o1-images'
const endpoint = skillConfig.oss_endpoint || 'oss-cn-shanghai.aliyuncs.com'
const prefix = (skillConfig.oss_prefix || '550e8400-e29b-41d4-a716-446655442082/external_storage').replace(/\/+$/, '')
// 从 process.env 读取(index.js 启动时已从 Toolbox_local_creds.env.local 加载)
let accessKeyId = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_ID || ''
let accessKeySecret = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_SECRET || ''
if (!accessKeyId || !accessKeySecret) {
throw new Error('OSS credentials not found. Please set oss_access_key_id and oss_access_key_secret in config.json')
}
/**
* 规范化 OSS key:处理斜杠、去除危险字符
*/
const normalizeKey = (relativePath) => {
let k = String(relativePath || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/')
if (k.startsWith('/')) k = k.slice(1)
return k
}
/**
* 拼接完整 OSS key(自动加前缀)
*/
const fullKey = (relativePath) => {
const rel = normalizeKey(relativePath)
return rel ? `${prefix}/${rel}` : prefix
}
/**
* URL 编码 OSS key(用于 HTTP 路径)
*/
const encodeKey = (key) => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/')
/**
* 构建 OSS 资源标识
*/
const ossResource = (key) => `/${bucket}/${String(key || '').replace(/^\/+/, '')}`
/**
* OSS 请求签名(HMAC-SHA1)
*/
const signRequest = ({ method, contentType = '', date, key, extraHeaders = {} }) => {
const canonicalHeaders = Object.keys(extraHeaders)
.filter(k => k && k.toLowerCase().startsWith('x-oss-'))
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(k => `${k.toLowerCase()}:${String(extraHeaders[k] || '').trim()}`)
.join('\n')
const resource = ossResource(key)
const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
return crypto.createHmac('sha1', accessKeySecret).update(stringToSign).digest('base64')
}
/**
* 发起 HTTP 请求(支持 body 为流)
*/
const httpRequest = (options, body = null, timeoutMs = 600000) => new Promise((resolve, reject) => {
const lib = options.protocol === 'https:' ? https : http
const req = lib.request(options, (res) => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const buf = Buffer.concat(chunks)
resolve({ statusCode: res.statusCode, headers: res.headers, body: buf })
})
})
req.on('error', reject)
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')))
if (body !== null && body !== undefined) {
if (body.pipe) {
body.pipe(req)
} else if (Buffer.isBuffer(body) || typeof body === 'string') {
req.write(body)
req.end()
} else {
req.end()
}
} else {
req.end()
}
})
/**
* 获取文件流
*/
const getObjectStream = (relativePath) => new Promise((resolve, reject) => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key })}`
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const req = https.request(options, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 500)}`)))
return
}
resolve({ stream: res, headers: res.headers })
})
req.on('error', reject)
req.setTimeout(60000, () => req.destroy(new Error('request timeout')))
req.end()
})
/**
* 上传文件
* @param {string} relativePath - 相对路径(自动加前缀)
* @param {Buffer|string} body - 文件内容 Buffer 或本地文件路径
* @param {string} contentType - MIME 类型
* @returns {Object} { key, raw_url }
*/
const putObject = async (relativePath, body, contentType = 'application/octet-stream') => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const extraHeaders = { 'x-oss-object-acl': 'public-read' }
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'PUT', contentType, date, key, extraHeaders })}`
let contentLength = 0
let stream = null
let sendBody = body
// 如果是字符串,假设是本地文件路径
if (typeof body === 'string' && fs.existsSync(body)) {
const stat = fs.statSync(body)
contentLength = stat.size
stream = fs.createReadStream(body)
sendBody = stream
} else if (Buffer.isBuffer(body)) {
contentLength = body.length
sendBody = body
}
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'PUT',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
'Content-Type': contentType,
'Content-Length': contentLength,
...extraHeaders,
Authorization: authorization
}
}
const resp = await httpRequest(options, sendBody, 3600000) // 1小时超时,大文件需要
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
const raw_url = `https://${bucket}.${endpoint}/${encodeKey(key)}`
return { key, raw_url }
}
/**
* 删除文件
*/
const deleteObject = async (relativePath) => {
const key = fullKey(relativePath)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'DELETE', date, key })}`
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/${encodeKey(key)}`,
method: 'DELETE',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
return true
}
/**
* 解析 OSS ListObjects XML 响应
*/
const parseListXml = (xml) => {
const extractTag = (tag, src) => {
const open = `<${tag}>`
const close = `</${tag}>`
const result = []
let idx = 0
while (true) {
const s = src.indexOf(open, idx)
if (s < 0) break
const e = src.indexOf(close, s + open.length)
if (e < 0) break
result.push(src.slice(s + open.length, e))
idx = e + close.length
}
return result
}
const extractInSection = (tag, sectionOpen, sectionClose, src) => {
const result = []
let searchFrom = 0
while (true) {
const sOpen = src.indexOf(sectionOpen, searchFrom)
if (sOpen < 0) break
const sClose = src.indexOf(sectionClose, sOpen)
if (sClose < 0) break
const section = src.slice(sOpen, sClose)
result.push(...extractTag(tag, section))
searchFrom = sClose + sectionClose.length
}
return result
}
const folders = extractInSection('Prefix', '<CommonPrefixes>', '</CommonPrefixes>', xml).map(p => ({
key: p,
name: p.split('/').filter(Boolean).pop() + '/',
isFolder: true,
size: 0
}))
const fileKeys = extractTag('Key', xml)
const fileSizes = extractTag('Size', xml)
const fileLms = extractTag('LastModified', xml)
const files = fileKeys.map((k, i) => ({
key: k,
name: k.split('/').filter(Boolean).pop(),
isFolder: false,
size: parseInt(fileSizes[i] || '0', 10) || 0,
lastModified: fileLms[i] ? Date.parse(fileLms[i]) : 0
})).filter(f => f.key && !f.key.endsWith('/'))
return { folders, files }
}
/**
* 列出文件(递归)
* @param {string} relativePrefix - 相对前缀
* @returns {Array} 文件列表
*/
const listRecursive = async (relativePrefix = '') => {
const all = []
const stack = [relativePrefix]
while (stack.length > 0) {
const current = stack.pop()
const keyPrefix = fullKey(current)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
const query = new URLSearchParams()
query.set('prefix', keyPrefix)
query.set('delimiter', '/')
query.set('max-keys', '1000')
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/?${query.toString()}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
const parsed = parseListXml(resp.body.toString('utf8'))
all.push(...parsed.files)
for (const f of parsed.folders) {
stack.push(f.key.slice(keyPrefix.length > 0 ? (fullKey('').length) : 0))
}
}
return all
}
/**
* 列出对象(单层)
*/
const listObjects = async (relativePrefix = '') => {
const keyPrefix = fullKey(relativePrefix)
const date = new Date().toUTCString()
const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
const query = new URLSearchParams()
query.set('prefix', keyPrefix)
query.set('delimiter', '/')
query.set('max-keys', '1000')
const options = {
hostname: `${bucket}.${endpoint}`,
path: `/?${query.toString()}`,
method: 'GET',
headers: {
Host: `${bucket}.${endpoint}`,
Date: date,
Authorization: authorization
}
}
const resp = await httpRequest(options)
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
}
return parseListXml(resp.body.toString('utf8'))
}
/**
* 获取文件原始 URL(公共读可直接访问)
*/
const getRawUrl = (relativePath) => {
const key = fullKey(relativePath)
return `https://${bucket}.${endpoint}/${encodeKey(key)}`
}
return {
fullKey,
normalizeKey,
putObject,
deleteObject,
getObjectStream,
listObjects,
listRecursive,
getRawUrl
}
}
module.exports = { createOssClient }
@@ -0,0 +1,10 @@
{
"_comment": "天气数据 skill - 由外部 Hermes Agent 每30分钟写入一次",
"id": "weather",
"name": "天气数据",
"enabled": true,
"mode": "append",
"mode_comment": "append: 增量追加保留所有历史;latest: 只保留最新一条",
"api_key_hash": "sha256:7b4035f766bf66e39cff1a58a5e8c73f2fcbf75f3f2a368bf0003e1110a56255",
"read_token_hash": "sha256:aa40833cd71a76b2fc558edb1384f4d993c9e8d966a730005f7c99d56ab19037"
}
+123
View File
@@ -0,0 +1,123 @@
// ============================================================
// data_gateway/store.js - 数据存储模块
// 职责:动态创建/管理每个 skill 的独立 SQLite 数据库
// 表结构:id | source | data(JSON) | created_at
// 支持 mode: "append"(增量追加)和 "latest"(覆盖最新)
// ============================================================
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const DATA_DIR = path.join(process.cwd(), 'data', 'ingest')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
// 已打开的数据库连接缓存
const dbCache = new Map()
const getDbPath = (skillId) => path.join(DATA_DIR, `${skillId}.db`)
const getOrOpenDb = (skillId) => {
if (dbCache.has(skillId)) return dbCache.get(skillId)
const dbPath = getDbPath(skillId)
const db = new Database(dbPath)
// 启用 WAL 模式提高并发写入性能
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.exec(
`CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
)
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at DESC)`)
dbCache.set(skillId, db)
return db
}
// 写入一条记录
const insert = (skillId, { source = '', data = {} }) => {
const db = getOrOpenDb(skillId)
const dataStr = JSON.stringify(data)
const info = db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
String(source || ''), dataStr
)
return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
}
// 覆盖写入(mode: "latest")- 先删后插,保证只有一条
const upsertLatest = (skillId, { source = '', data = {} }) => {
const db = getOrOpenDb(skillId)
const dataStr = JSON.stringify(data)
const tx = db.transaction(() => {
db.prepare(`DELETE FROM records`).run()
return db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
String(source || ''), dataStr
)
})
const info = tx()
return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
}
// 查询最新一条记录
const queryLatest = (skillId) => {
const db = getOrOpenDb(skillId)
const row = db.prepare(`SELECT * FROM records ORDER BY id DESC LIMIT 1`).get()
if (!row) return null
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
return row
}
// 查询记录列表(分页)
const queryList = (skillId, { limit = 100, offset = 0, source = null } = {}) => {
const db = getOrOpenDb(skillId)
let sql = `SELECT * FROM records`
const params = []
if (source) {
sql += ` WHERE source = ?`
params.push(String(source))
}
sql += ` ORDER BY id DESC LIMIT ? OFFSET ?`
params.push(Math.min(Number(limit) || 100, 1000), Math.max(0, Number(offset) || 0))
const rows = db.prepare(sql).all(...params)
for (const row of rows) {
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
}
const total = db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
return { rows, total }
}
// 按时间范围查询
const queryByTimeRange = (skillId, { start, end, limit = 100 } = {}) => {
const db = getOrOpenDb(skillId)
let sql = `SELECT * FROM records WHERE 1=1`
const params = []
if (start) { sql += ` AND created_at >= ?`; params.push(String(start)) }
if (end) { sql += ` AND created_at <= ?`; params.push(String(end)) }
sql += ` ORDER BY id DESC LIMIT ?`
params.push(Math.min(Number(limit) || 100, 1000))
const rows = db.prepare(sql).all(...params)
for (const row of rows) {
try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
}
return { rows, total: rows.length }
}
// 获取记录总数
const count = (skillId) => {
const db = getOrOpenDb(skillId)
return db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
}
// 关闭所有数据库连接
const closeAll = () => {
for (const [skillId, db] of dbCache) {
try { db.close() } catch {}
dbCache.delete(skillId)
}
}
module.exports = {
insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count, closeAll, getDbPath, getOrOpenDb
}
+218
View File
@@ -0,0 +1,218 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'toolbox.db')
const db = new Database(dbPath)
db.exec(
`CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL,
mid TEXT NOT NULL UNIQUE,
screen_name TEXT,
created_at TEXT,
text TEXT,
text_plain TEXT,
url TEXT,
reposts_count INTEGER,
comments_count INTEGER,
attitudes_count INTEGER,
pics_json TEXT,
fetched_at TEXT
);`
)
// migrations: add retweeted_* columns if missing
const cols = db.prepare(`PRAGMA table_info(posts)`).all().map(r => r.name)
const addCol = name => db.exec(`ALTER TABLE posts ADD COLUMN ${name} TEXT`)
if (!cols.includes('retweeted_mid')) addCol('retweeted_mid')
if (!cols.includes('retweeted_screen_name')) addCol('retweeted_screen_name')
if (!cols.includes('retweeted_text')) addCol('retweeted_text')
if (!cols.includes('retweeted_text_plain')) addCol('retweeted_text_plain')
if (!cols.includes('retweeted_url')) addCol('retweeted_url')
if (!cols.includes('retweeted_pics_json')) addCol('retweeted_pics_json')
db.exec(
`CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS tracked_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL UNIQUE,
screen_name TEXT,
added_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS settings_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL,
payload TEXT NOT NULL,
saved_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(uid, week_start)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS market_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill TEXT NOT NULL,
alert_id TEXT NOT NULL,
slot_key TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(skill, alert_id)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS market_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS alert_email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill TEXT NOT NULL,
alert_id TEXT NOT NULL,
subject TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TEXT DEFAULT (datetime('now'))
);`
)
// 唯一索引:同一个告警事件同一种状态不重复记录
try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_email_log_uniq ON alert_email_log(skill, alert_id, status)`) } catch {}
const insertPost = db.prepare(
`INSERT OR IGNORE INTO posts (
uid, mid, screen_name, created_at, text, text_plain, url,
reposts_count, comments_count, attitudes_count, pics_json, fetched_at
) VALUES (
@uid, @mid, @screen_name, @created_at, @text, @text_plain, @url,
@reposts_count, @comments_count, @attitudes_count, @pics_json, @fetched_at
)`
)
const updateRetweeted = db.prepare(
`UPDATE posts SET
retweeted_mid = @retweeted_mid,
retweeted_screen_name = @retweeted_screen_name,
retweeted_text = @retweeted_text,
retweeted_text_plain = @retweeted_text_plain,
retweeted_url = @retweeted_url,
retweeted_pics_json = @retweeted_pics_json,
text_plain = @text_plain
WHERE mid = @mid`
)
const queryPostsAny = (uid, terms, limit) => {
const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' OR ')
const args = terms.map(t => `%${t}%`)
const stmt = db.prepare(
`SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?`
)
return stmt.all(uid, ...args, limit)
}
const queryPostsAll = (uid, terms, limit) => {
const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' AND ')
const args = terms.map(t => `%${t}%`)
const stmt = db.prepare(
`SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?`
)
return stmt.all(uid, ...args, limit)
}
const insertKeyword = db.prepare(
`INSERT OR IGNORE INTO keywords (keyword) VALUES (?)`
)
const insertTrackedUser = db.prepare(
`INSERT OR IGNORE INTO tracked_users (uid, screen_name) VALUES (?, ?)`
)
const getSettings = () => {
const keywords = db.prepare(`SELECT keyword FROM keywords ORDER BY id DESC`).all().map(r => r.keyword)
const users = db.prepare(`SELECT uid, screen_name FROM tracked_users ORDER BY id DESC`).all()
return { keywords, users }
}
const insertSettingHistory = db.prepare(
`INSERT INTO settings_history (tool, payload) VALUES (@tool, @payload)`
)
const upsertArchive = db.prepare(
`INSERT INTO archives (uid, week_start, week_end, html, created_at, updated_at)
VALUES (@uid, @week_start, @week_end, @html, datetime('now'), datetime('now'))
ON CONFLICT(uid, week_start) DO UPDATE SET
week_end = excluded.week_end,
html = excluded.html,
updated_at = datetime('now')`
)
const getArchive = db.prepare(
`SELECT * FROM archives WHERE uid = ? AND week_start = ?`
)
const upsertMarketArchive = db.prepare(
`INSERT INTO market_archives (day, html, created_at, updated_at)
VALUES (@day, @html, datetime('now'), datetime('now'))
ON CONFLICT(day) DO UPDATE SET
html = excluded.html,
updated_at = datetime('now')`
)
const getMarketArchive = db.prepare(
`SELECT * FROM market_archives WHERE day = ?`
)
module.exports = {
db,
insertPost,
updateRetweeted,
queryPostsAny,
queryPostsAll,
insertKeyword,
insertTrackedUser,
getSettings
, upsertArchive
, getArchive
, insertSettingHistory
, upsertMarketArchive
, getMarketArchive
}
+105
View File
@@ -0,0 +1,105 @@
const axios = require('axios')
let WebSocket
try { WebSocket = require('ws') } catch { WebSocket = null }
const resolveWsUrl = async () => {
const tryList = [
'http://localhost:9222/json/version',
'http://127.0.0.1:9222/json/version',
'http://localhost:9222/json',
'http://127.0.0.1:9222/json'
]
for (const u of tryList) {
try {
const res = await axios.get(u, { timeout: 5000 })
if (res && res.data) {
if (Array.isArray(res.data)) {
const item = res.data.find(x => x.webSocketDebuggerUrl) || res.data[0]
const ws = item && item.webSocketDebuggerUrl ? item.webSocketDebuggerUrl : ''
if (ws) return ws
} else {
const ws = res.data.webSocketDebuggerUrl || ''
if (ws) return ws
}
}
} catch {}
}
return ''
}
const getCookiesFromDevTools = async (urls = ['https://m.weibo.cn', 'https://weibo.com']) => {
if (!WebSocket) return ''
try {
const wsUrl = await resolveWsUrl()
if (!wsUrl) return ''
const ws = new WebSocket(wsUrl)
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject) })
const send = (id, method, params) => new Promise((resolve, reject) => {
const handler = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.id === id) { ws.off('message', handler); resolve(msg.result || {}) }
} catch (e) { ws.off('message', handler); reject(e) }
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
await send(1, 'Network.enable', {})
const res = await send(2, 'Storage.getCookies', { urls })
try { ws.close() } catch {}
const pairs = ((res && res.cookies) || []).filter(c => {
const domain = String(c.domain || '').toLowerCase()
return domain.includes('weibo') || domain.includes('.weibo') ||
domain.includes('weibo.cn') || domain.includes('weibo.com') ||
domain.includes('sina.com') || domain.includes('sina.cn')
}).map(c => `${c.name}=${c.value}`)
return pairs.join('; ')
} catch { return '' }
}
const screenshotUrl = async (url) => {
if (!WebSocket) return ''
try {
const wsUrl = await resolveWsUrl()
if (!wsUrl) return ''
const ws = new WebSocket(wsUrl)
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject) })
let sessionId = ''
const send = (id, method, params, sid) => new Promise((resolve, reject) => {
const handler = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.id === id) { ws.off('message', handler); resolve(msg.result || {}) }
} catch (e) { ws.off('message', handler); reject(e) }
}
ws.on('message', handler)
const payload = sid ? { id, method, params, sessionId: sid } : { id, method, params }
ws.send(JSON.stringify(payload))
})
const waitForLoad = (sid) => new Promise((resolve) => {
const onMsg = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.sessionId === sid && msg.method === 'Page.loadEventFired') { ws.off('message', onMsg); resolve() }
} catch {}
}
ws.on('message', onMsg)
setTimeout(() => { ws.off('message', onMsg); resolve() }, 10000)
})
const target = await send(1, 'Target.createTarget', { url: 'about:blank' })
const attach = await send(2, 'Target.attachToTarget', { targetId: target && target.targetId, flatten: true })
sessionId = attach && attach.sessionId ? attach.sessionId : ''
if (!sessionId) { try { ws.close() } catch {}; return '' }
await send(3, 'Page.enable', {}, sessionId)
await send(4, 'Network.enable', {}, sessionId)
await send(5, 'Page.navigate', { url }, sessionId)
await waitForLoad(sessionId)
const shot = await send(6, 'Page.captureScreenshot', { format: 'png', fromSurface: true }, sessionId)
try { await send(7, 'Target.closeTarget', { targetId: target && target.targetId }) } catch {}
try { ws.close() } catch {}
return shot && shot.data ? shot.data : ''
} catch { return '' }
}
module.exports = { getCookiesFromDevTools, screenshotUrl }
+137
View File
@@ -0,0 +1,137 @@
const express = require('express')
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'dlt_draws.db')
const db = new Database(dbPath)
// Initialize Tables
db.exec(`CREATE TABLE IF NOT EXISTS draws (
issue TEXT PRIMARY KEY,
date TEXT,
front TEXT,
back TEXT,
pool INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);`)
// Auth Helpers
const readCookieCfg = () => {
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'dlt_draws', 'assets', 'auth_config.json')
let name = 'dlt_draws_gate'
let maxAge = 90 * 24 * 3600 * 1000
try {
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (cfg.cookieName) name = String(cfg.cookieName)
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
}
} catch {}
return { name, maxAge }
}
const parseCookie = (cookieStr) => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => { const idx = p.indexOf('='); if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1)) })
return out
}
const checkAuth = (req, res, next) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name, maxAge } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') {
res.cookie(name, '1', { httpOnly: true, sameSite: 'lax', maxAge })
return next()
}
} catch {}
return res.status(401).json({ error: 'unauthorized' })
}
// Helpers
const pad2 = (n) => String(n).padStart(2, '0')
const arrToCsv = (arr) => (Array.isArray(arr) ? arr : String(arr).split(/[,,\s]+/).filter(Boolean))
.map((x) => pad2(parseInt(x, 10)))
.join(',')
const csvToArr = (csv) => String(csv).split(/[,,\s]+/).filter(Boolean).map((x) => parseInt(x, 10))
const bindRoutes = (app) => {
// Routes prefixed with /api/dlt
app.get('/auth/handshake', (req, res) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/auth/token', (req, res) => {
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
})
app.get('/api/dlt/health', (req, res) => res.json({ status: 'ok' }))
app.get('/api/dlt/draws', checkAuth, (req, res) => {
const limit = Math.max(0, parseInt(req.query.limit || '0', 10) || 0)
const base = `SELECT issue, date, front, back, pool FROM draws ORDER BY CAST(issue AS INTEGER) ASC`
const sql = limit ? `${base} LIMIT ?` : base
try {
const rows = db.prepare(sql).all(limit ? [limit] : [])
res.json(rows.map((r) => ({
issue: r.issue,
date: r.date,
front: csvToArr(r.front),
back: csvToArr(r.back),
pool: r.pool || 0
})))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/dlt/draws/merge', checkAuth, (req, res) => {
const items = Array.isArray(req.body.rows) ? req.body.rows : []
if (!items.length) return res.status(400).json({ error: 'rows empty' })
let inserted = 0
try {
const insert = db.transaction((rows) => {
let count = 0
const stmt = db.prepare('INSERT OR IGNORE INTO draws(issue,date,front,back,pool) VALUES (?,?,?,?,?)')
for (const it of rows) {
const issue = String(it.issue || '').trim()
const date = String(it.date || '').trim()
const front = arrToCsv(it.front || it.frontArr || [])
const back = arrToCsv(it.back || it.backArr || [])
const pool = Number(it.pool || 0)
if (!issue || csvToArr(front).length !== 5 || csvToArr(back).length !== 2) continue
const info = stmt.run(issue, date, front, back, pool)
count += info.changes
}
return count
})
inserted = insert(items)
res.json({ inserted })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/dlt/draws/clear', checkAuth, (req, res) => {
try {
db.prepare('DELETE FROM draws').run()
res.json({ cleared: true })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
}
module.exports = { bindRoutes }
+177
View File
@@ -0,0 +1,177 @@
const path = require('path')
const fs = require('fs')
const axios = require('axios')
const Database = require('better-sqlite3')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'doc_calendar_bridge.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS bridge_sync (
doc_id INTEGER PRIMARY KEY,
event_id TEXT NOT NULL,
status TEXT NOT NULL,
synced_at TEXT DEFAULT (datetime('now','+8 hours')),
last_error TEXT DEFAULT '',
payload_json TEXT DEFAULT ''
);`)
const upsertSyncStmt = db.prepare(`INSERT INTO bridge_sync (doc_id, event_id, status, synced_at, last_error, payload_json) VALUES (@doc_id, @event_id, @status, datetime('now','+8 hours'), @last_error, @payload_json)
ON CONFLICT(doc_id) DO UPDATE SET event_id=excluded.event_id, status=excluded.status, synced_at=datetime('now','+8 hours'), last_error=excluded.last_error, payload_json=excluded.payload_json`)
const getSyncByDocStmt = db.prepare(`SELECT doc_id, event_id, status, synced_at, last_error, payload_json FROM bridge_sync WHERE doc_id=?`)
const readCalendarPublicKey = () => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const key = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
if (key) return key
}
} catch {}
return '099239923'
}
const calendarEndpoint = () => {
const port = process.env.PORT || 8976
const key = readCalendarPublicKey()
return `http://127.0.0.1:${port}/api/calendar/public/events?key=${encodeURIComponent(key)}`
}
const parseBeijingDateTimeToMs = raw => {
const s = String(raw || '').trim()
if (!s) return NaN
const normalized = s.includes('T') ? s : s.replace(' ', 'T')
const withSeconds = normalized.length === 16 ? `${normalized}:00` : normalized
const iso = withSeconds.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(withSeconds) ? withSeconds : `${withSeconds}+08:00`
const ms = new Date(iso).getTime()
return Number.isFinite(ms) ? ms : NaN
}
const toIso = ms => {
const n = Number(ms || 0)
if (!Number.isFinite(n) || n <= 0) return null
return new Date(n).toISOString()
}
const defaultDueAtToday18Beijing = () => {
const now = new Date()
const utcMs = now.getTime() + (now.getTimezoneOffset() * 60 * 1000)
const bj = new Date(utcMs + 8 * 60 * 60 * 1000)
const y = bj.getUTCFullYear()
const m = String(bj.getUTCMonth() + 1).padStart(2, '0')
const d = String(bj.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${d}T18:00`
}
const buildEventPayload = (doc, folderName) => {
const docId = parseInt(String((doc && doc.id) || '0'), 10) || 0
if (!docId) throw new Error('bad_doc_id')
const eventId = `dck-${docId}`
const createdMs = parseBeijingDateTimeToMs((doc && doc.created_at) || '')
const dueRaw = String((doc && doc.due_at) || '').trim() || defaultDueAtToday18Beijing()
const dueMs = parseBeijingDateTimeToMs(dueRaw)
const startIso = toIso(createdMs || Date.now())
const endIso = toIso(dueMs || (Date.now() + 60 * 60 * 1000))
const status = String((doc && doc.status) || 'todo').toLowerCase()
const completed = status === 'done'
const priority = String((doc && doc.priority) || 'medium').toLowerCase()
const lines = [
`来源: 文档云管家`,
`文档ID: ${docId}`,
`优先级: ${priority}`,
`状态: ${status}`,
`文件夹: ${String(folderName || '根目录')}`,
`备注: ${String((doc && doc.note) || '')}`,
`链接: /tools/doc_cloud_keeper?from=calendar&doc_id=${docId}`
]
const content = lines.join('\n').trim()
const remindMs = completed ? NaN : (dueMs - 24 * 3600 * 1000)
return {
id: eventId,
source: 'doc_cloud_keeper',
external_id: String(docId),
title: String((doc && doc.filename) || `文档#${docId}`),
content,
start: startIso,
end: endIso,
deadline: endIso,
remindAt: Number.isFinite(remindMs) ? toIso(remindMs) : null,
allDay: false,
completed,
deleted: false,
priority,
status_code: completed ? 'done' : 'new',
status_label: completed ? '✅已完成' : '📄文档待办',
updatedAt: new Date().toISOString()
}
}
const postEvent = async payload => {
const url = calendarEndpoint()
await axios.post(url, payload, { timeout: 10000, headers: { 'Content-Type': 'application/json' } })
}
const deleteEvent = async eventId => {
const url = `${calendarEndpoint()}&_method=DELETE&id=${encodeURIComponent(String(eventId || ''))}`
await axios.post(url, {}, { timeout: 10000, headers: { 'Content-Type': 'application/json' } })
}
const upsert = async (doc, folderName) => {
const payload = buildEventPayload(doc, folderName)
try {
await postEvent(payload)
upsertSyncStmt.run({
doc_id: parseInt(String(doc.id || '0'), 10) || 0,
event_id: payload.id,
status: 'synced',
last_error: '',
payload_json: JSON.stringify(payload)
})
return { ok: true, event_id: payload.id }
} catch (e) {
upsertSyncStmt.run({
doc_id: parseInt(String(doc.id || '0'), 10) || 0,
event_id: payload.id,
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: JSON.stringify(payload)
})
return { ok: false, event_id: payload.id, error: String(e && e.message ? e.message : e) }
}
}
const remove = async docId => {
const did = parseInt(String(docId || '0'), 10) || 0
if (!did) return { ok: false, error: 'bad_doc_id' }
const eventId = `dck-${did}`
try {
await deleteEvent(eventId)
upsertSyncStmt.run({
doc_id: did,
event_id: eventId,
status: 'deleted',
last_error: '',
payload_json: ''
})
return { ok: true, event_id: eventId }
} catch (e) {
upsertSyncStmt.run({
doc_id: did,
event_id: eventId,
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: ''
})
return { ok: false, event_id: eventId, error: String(e && e.message ? e.message : e) }
}
}
const byDocId = docId => getSyncByDocStmt.get(parseInt(String(docId || '0'), 10) || 0) || null
module.exports = {
upsert,
remove,
byDocId
}
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
const fs = require('fs')
const path = require('path')
const XLSX = require('xlsx')
const mammoth = require('mammoth')
const pdfParse = require('pdf-parse')
const unzipper = require('unzipper')
const MAX_DETAIL_ITEMS = 30
const normalizeText = text => String(text || '').replace(/\r/g, '')
const trimLine = text => String(text || '').replace(/\s+/g, ' ').trim()
const nonEmptyLines = text => normalizeText(text).split('\n').map(trimLine).filter(Boolean)
const shortText = (text, max = 120) => {
const s = trimLine(text)
if (s.length <= max) return s
return `${s.slice(0, max - 3)}...`
}
const labelValue = value => {
if (value == null) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') {
try { return JSON.stringify(value) } catch { return String(value) }
}
return String(value)
}
const lineDiff = (leftLines, rightLines, maxItems = MAX_DETAIL_ITEMS) => {
const leftMap = new Map()
const rightMap = new Map()
leftLines.forEach(line => leftMap.set(line, (leftMap.get(line) || 0) + 1))
rightLines.forEach(line => rightMap.set(line, (rightMap.get(line) || 0) + 1))
const added = []
const removed = []
rightLines.forEach(line => {
const leftCount = leftMap.get(line) || 0
const rightCount = (rightMap.get(line) || 0) - 1
rightMap.set(line, rightCount)
if (leftCount > 0) {
leftMap.set(line, leftCount - 1)
return
}
if (added.length < maxItems) added.push(shortText(line))
})
leftLines.forEach(line => {
const remaining = leftMap.get(line) || 0
if (remaining <= 0) return
leftMap.set(line, remaining - 1)
if (removed.length < maxItems) removed.push(shortText(line))
})
return { added, removed }
}
const baseSummary = (left, right) => {
const items = []
if (String(left.filename || '') !== String(right.filename || '')) {
items.push(`文件名变化:${left.filename || '-'} -> ${right.filename || '-'}`)
} else {
items.push(`文件名一致:${left.filename || '-'}`)
}
if (Number(left.size_bytes || 0) !== Number(right.size_bytes || 0)) {
items.push(`文件大小变化:${left.size_bytes || 0} bytes -> ${right.size_bytes || 0} bytes`)
} else {
items.push(`文件大小一致:${left.size_bytes || 0} bytes`)
}
return items
}
const readZipEntryText = async (filePath, entryName) => {
const archive = await unzipper.Open.file(filePath)
const entry = archive.files.find(item => item.path === entryName)
if (!entry) return ''
const buffer = await entry.buffer()
return buffer.toString('utf8')
}
const detectFileType = ext => {
const e = String(ext || '').toLowerCase()
if (e === 'xls' || e === 'xlsx') return 'excel'
if (e === 'docx') return 'word'
if (e === 'pdf') return 'pdf'
return 'unknown'
}
const compareCommonMeta = (left, right) => ({
left: {
label: left.label,
filename: left.filename,
size_bytes: left.size_bytes || 0,
created_at: left.created_at || '',
ext: left.ext || ''
},
right: {
label: right.label,
filename: right.filename,
size_bytes: right.size_bytes || 0,
created_at: right.created_at || '',
ext: right.ext || ''
}
})
const worksheetShape = sheet => {
const ref = String((sheet && sheet['!ref']) || 'A1')
try {
const range = XLSX.utils.decode_range(ref)
return {
rows: range.e.r - range.s.r + 1,
cols: range.e.c - range.s.c + 1
}
} catch {
return { rows: 0, cols: 0 }
}
}
const cellMap = sheet => {
const map = new Map()
Object.keys(sheet || {}).forEach(key => {
if (key.startsWith('!')) return
const cell = sheet[key] || {}
map.set(key, {
value: labelValue(Object.prototype.hasOwnProperty.call(cell, 'v') ? cell.v : (cell.w || '')),
formula: String(cell.f || '')
})
})
return map
}
const compareExcel = (left, right) => {
const wbLeft = XLSX.readFile(left.full_path, { cellFormula: true })
const wbRight = XLSX.readFile(right.full_path, { cellFormula: true })
const sheetNames = Array.from(new Set([...(wbLeft.SheetNames || []), ...(wbRight.SheetNames || [])]))
const addedSheets = (wbRight.SheetNames || []).filter(name => !(wbLeft.SheetNames || []).includes(name))
const removedSheets = (wbLeft.SheetNames || []).filter(name => !(wbRight.SheetNames || []).includes(name))
const changedSheets = []
const changedSheetStats = []
const cellChangesBySheet = new Map()
let changedCellCount = 0
let changedFormulaCount = 0
sheetNames.forEach(name => {
const leftSheet = wbLeft.Sheets[name]
const rightSheet = wbRight.Sheets[name]
if (!leftSheet || !rightSheet) return
const leftShape = worksheetShape(leftSheet)
const rightShape = worksheetShape(rightSheet)
const leftCells = cellMap(leftSheet)
const rightCells = cellMap(rightSheet)
const addresses = Array.from(new Set([...leftCells.keys(), ...rightCells.keys()])).sort()
let sheetChangedCells = 0
let sheetChangedFormulas = 0
const sheetDetails = []
addresses.forEach(addr => {
const lv = leftCells.get(addr) || { value: '', formula: '' }
const rv = rightCells.get(addr) || { value: '', formula: '' }
if (lv.formula !== rv.formula) {
changedFormulaCount += 1
sheetChangedFormulas += 1
if (sheetDetails.length < MAX_DETAIL_ITEMS) {
sheetDetails.push(`${name}!${addr} 公式变化:${lv.formula || '(空)'} -> ${rv.formula || '(空)'}`)
}
}
if (lv.value !== rv.value) {
changedCellCount += 1
sheetChangedCells += 1
if (sheetDetails.length < MAX_DETAIL_ITEMS) {
sheetDetails.push(`${name}!${addr} 内容变化:${shortText(lv.value, 40) || '(空)'} -> ${shortText(rv.value, 40) || '(空)'}`)
}
}
})
if (sheetChangedCells || sheetChangedFormulas || leftShape.rows !== rightShape.rows || leftShape.cols !== rightShape.cols) {
changedSheets.push(`${name}:单元格变化 ${sheetChangedCells} 处,公式变化 ${sheetChangedFormulas} 处,范围 ${leftShape.rows}x${leftShape.cols} -> ${rightShape.rows}x${rightShape.cols}`)
changedSheetStats.push({
sheet_name: name,
cell_changes: sheetChangedCells,
formula_changes: sheetChangedFormulas,
left_rows: leftShape.rows,
left_cols: leftShape.cols,
right_rows: rightShape.rows,
right_cols: rightShape.cols,
detail_count: sheetDetails.length
})
cellChangesBySheet.set(name, sheetDetails)
}
})
const summaryItems = [
...baseSummary(left, right),
`工作表数量:${(wbLeft.SheetNames || []).length} -> ${(wbRight.SheetNames || []).length}`,
`新增工作表:${addedSheets.length}`,
`删除工作表:${removedSheets.length}`,
`发生变化的工作表:${changedSheets.length}`,
`内容变化单元格:${changedCellCount}`,
`公式变化:${changedFormulaCount}`
]
const detailSections = []
if (addedSheets.length) detailSections.push({ title: '新增工作表', items: addedSheets.map(name => shortText(name)) })
if (removedSheets.length) detailSections.push({ title: '删除工作表', items: removedSheets.map(name => shortText(name)) })
if (changedSheets.length) detailSections.push({ title: '工作表变化摘要', items: changedSheets.slice(0, MAX_DETAIL_ITEMS) })
if (changedSheetStats.length) {
changedSheetStats.forEach(stat => {
detailSections.push({
title: `${stat.sheet_name} - 关键单元格变化`,
sheet_name: stat.sheet_name,
items: (cellChangesBySheet.get(stat.sheet_name) || []).slice(0, MAX_DETAIL_ITEMS)
})
})
}
return {
file_type: 'excel',
warnings: [],
summary_items: summaryItems,
detail_sections: detailSections,
excel_stats: {
total_sheets_left: (wbLeft.SheetNames || []).length,
total_sheets_right: (wbRight.SheetNames || []).length,
added_sheets: addedSheets,
removed_sheets: removedSheets,
changed_sheet_count: changedSheetStats.length,
changed_cell_count: changedCellCount,
changed_formula_count: changedFormulaCount,
changed_sheets: changedSheetStats
}
}
}
const extractDocxInfo = async filePath => {
const raw = await mammoth.extractRawText({ path: filePath })
const xml = await readZipEntryText(filePath, 'word/document.xml')
const text = String((raw && raw.value) || '')
return {
paragraph_count: (xml.match(/<w:p\b/g) || []).length,
table_count: (xml.match(/<w:tbl\b/g) || []).length,
heading_count: (xml.match(/Heading[1-9]/g) || []).length,
text_length: text.length,
lines: nonEmptyLines(text)
}
}
const compareDocx = async (left, right) => {
const [leftInfo, rightInfo] = await Promise.all([extractDocxInfo(left.full_path), extractDocxInfo(right.full_path)])
const warnings = []
if (!leftInfo.lines.length && !rightInfo.lines.length) warnings.push('两个 DOCX 都没有提取到正文文本,结果可能只反映结构变化。')
const diff = lineDiff(leftInfo.lines, rightInfo.lines)
const summaryItems = [
...baseSummary(left, right),
`段落数量:${leftInfo.paragraph_count} -> ${rightInfo.paragraph_count}`,
`表格数量:${leftInfo.table_count} -> ${rightInfo.table_count}`,
`标题样式数量:${leftInfo.heading_count} -> ${rightInfo.heading_count}`,
`正文文本长度:${leftInfo.text_length} -> ${rightInfo.text_length}`,
`新增段落摘要:${diff.added.length}`,
`删除段落摘要:${diff.removed.length}`
]
const detailSections = []
if (diff.added.length) detailSections.push({ title: '新增段落摘要', items: diff.added })
if (diff.removed.length) detailSections.push({ title: '删除段落摘要', items: diff.removed })
return {
file_type: 'word',
warnings,
summary_items: summaryItems,
detail_sections: detailSections
}
}
const extractPdfInfo = async filePath => {
const parsed = await pdfParse(fs.readFileSync(filePath))
const text = String(parsed.text || '')
return {
page_count: Number(parsed.numpages || 0),
text_length: text.length,
lines: nonEmptyLines(text),
info: parsed.info || {}
}
}
const comparePdf = async (left, right) => {
const [leftInfo, rightInfo] = await Promise.all([extractPdfInfo(left.full_path), extractPdfInfo(right.full_path)])
const warnings = []
if (!leftInfo.text_length || !rightInfo.text_length) {
warnings.push('至少有一个 PDF 没有提取到足够文本,若是扫描件图片,第一版只能提供基础摘要。')
}
const diff = lineDiff(leftInfo.lines, rightInfo.lines)
const summaryItems = [
...baseSummary(left, right),
`页数:${leftInfo.page_count} -> ${rightInfo.page_count}`,
`提取文本长度:${leftInfo.text_length} -> ${rightInfo.text_length}`,
`新增文本摘要:${diff.added.length}`,
`删除文本摘要:${diff.removed.length}`
]
const detailSections = []
if (diff.added.length) detailSections.push({ title: '新增文本摘要', items: diff.added })
if (diff.removed.length) detailSections.push({ title: '删除文本摘要', items: diff.removed })
return {
file_type: 'pdf',
warnings,
summary_items: summaryItems,
detail_sections: detailSections
}
}
const compareUnknown = (left, right) => ({
file_type: 'unknown',
warnings: ['当前第一版只支持 Excel、DOCX、PDF 的结构化差异摘要。'],
summary_items: [
...baseSummary(left, right),
`文件类型:${left.ext || '-'} -> ${right.ext || '-'}`
],
detail_sections: []
})
const compareFiles = async (left, right) => {
if (!left || !right) throw new Error('compare_targets_missing')
if (!fs.existsSync(left.full_path)) throw new Error(`比较源文件不存在:${path.basename(left.full_path || '')}`)
if (!fs.existsSync(right.full_path)) throw new Error(`比较目标文件不存在:${path.basename(right.full_path || '')}`)
const typeLeft = detectFileType(left.ext)
const typeRight = detectFileType(right.ext)
const base = compareCommonMeta(left, right)
let result = null
if (typeLeft !== typeRight) {
result = {
file_type: 'mixed',
warnings: ['两个版本的文件类型不同,第一版仅提供基础摘要。'],
summary_items: [
...baseSummary(left, right),
`文件类型:${left.ext || '-'} -> ${right.ext || '-'}`
],
detail_sections: []
}
} else if (typeLeft === 'excel') {
result = compareExcel(left, right)
} else if (typeLeft === 'word') {
result = await compareDocx(left, right)
} else if (typeLeft === 'pdf') {
result = await comparePdf(left, right)
} else {
result = compareUnknown(left, right)
}
return {
...base,
...result
}
}
module.exports = {
compareFiles
}
+796
View File
@@ -0,0 +1,796 @@
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const axios = require('axios')
const { logJSON } = require('./logger')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'expense.db')
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
// Upload directories (independent for expense system)
const uploadsBase = path.join(process.cwd(), 'uploads', 'expense')
const chunksDir = path.join(uploadsBase, 'chunks')
const attachmentsDir = path.join(uploadsBase, 'attachments')
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true })
if (!fs.existsSync(chunksDir)) fs.mkdirSync(chunksDir, { recursive: true })
if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true })
db.exec(
`CREATE TABLE IF NOT EXISTS types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
cost_type TEXT DEFAULT '个人费用',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type_id INTEGER NOT NULL,
subject_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL,
content TEXT,
arrived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(type_id) REFERENCES types(id),
FOREIGN KEY(subject_id) REFERENCES subjects(id)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS expense_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expense_id INTEGER NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
mime TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(expense_id) REFERENCES expenses(id)
);`
)
try {
db.prepare('SELECT cost_type FROM expenses LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE expenses ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
try {
db.prepare('SELECT cost_type FROM types LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE types ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
try {
db.prepare('SELECT cost_type FROM subjects LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE subjects ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
db.exec(`UPDATE expenses SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
db.exec(`UPDATE types SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
db.exec(`UPDATE subjects SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
const listTypesStmt = db.prepare(`SELECT id, name, cost_type FROM types ORDER BY id ASC`)
const insertTypeStmt = db.prepare(`INSERT OR IGNORE INTO types (name, cost_type) VALUES (?, ?)`)
const updateTypeStmt = db.prepare(`UPDATE types SET name=?, cost_type=?, updated_at=datetime('now') WHERE id=?`)
const getTypeByNameStmt = db.prepare(`SELECT id, name, cost_type FROM types WHERE name=?`)
const deleteTypeStmt = db.prepare(`DELETE FROM types WHERE id=?`)
const countExpensesByTypeStmt = db.prepare(`SELECT COUNT(1) AS c FROM expenses WHERE type_id=?`)
const deleteExpensesByTypeStmt = db.prepare(`DELETE FROM expenses WHERE type_id=?`)
const listExpenseIdsByTypeStmt = db.prepare(`SELECT id FROM expenses WHERE type_id=?`)
const listSubjectsStmt = db.prepare(`SELECT id, name, cost_type FROM subjects ORDER BY id ASC`)
const insertSubjectStmt = db.prepare(`INSERT OR IGNORE INTO subjects (name, cost_type) VALUES (?, ?)`)
const updateSubjectStmt = db.prepare(`UPDATE subjects SET name=?, cost_type=?, updated_at=datetime('now') WHERE id=?`)
const getSubjectByNameStmt = db.prepare(`SELECT id, name, cost_type FROM subjects WHERE name=?`)
const deleteSubjectStmt = db.prepare(`DELETE FROM subjects WHERE id=?`)
const countExpensesBySubjectStmt = db.prepare(`SELECT COUNT(1) AS c FROM expenses WHERE subject_id=?`)
const deleteExpensesBySubjectStmt = db.prepare(`DELETE FROM expenses WHERE subject_id=?`)
const listExpenseIdsBySubjectStmt = db.prepare(`SELECT id FROM expenses WHERE subject_id=?`)
const listExpensesStmt = db.prepare(`
SELECT e.id, e.date, e.amount_cents, e.content, e.arrived, e.cost_type,
t.name AS type_name, s.name AS subject_name
FROM expenses e
LEFT JOIN types t ON e.type_id = t.id
LEFT JOIN subjects s ON e.subject_id = s.id
ORDER BY e.date ASC, e.id ASC
`)
const insertExpenseStmt = db.prepare(`
INSERT INTO expenses (date, type_id, subject_id, amount_cents, content, arrived, cost_type)
VALUES (@date, @type_id, @subject_id, @amount_cents, @content, @arrived, @cost_type)
`)
const updateExpenseStmt = db.prepare(`
UPDATE expenses SET
date=@date, type_id=@type_id, subject_id=@subject_id,
amount_cents=@amount_cents, content=@content, arrived=@arrived, cost_type=@cost_type,
updated_at=datetime('now')
WHERE id=@id
`)
const deleteExpenseStmt = db.prepare(`DELETE FROM expenses WHERE id=?`)
// Attachments statements
const listAttachmentsByExpenseStmt = db.prepare(`SELECT id, expense_id, filename, path, size_bytes, mime FROM expense_attachments WHERE expense_id=? ORDER BY id ASC`)
const insertAttachmentStmt = db.prepare(`INSERT INTO expense_attachments (expense_id, filename, path, size_bytes, mime) VALUES (@expense_id, @filename, @path, @size_bytes, @mime)`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, path FROM expense_attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM expense_attachments WHERE id=?`)
const listAttachmentIdPathByExpenseStmt = db.prepare(`SELECT id, path FROM expense_attachments WHERE expense_id=?`)
// Stats statements
const statsTotalStmt = db.prepare(`SELECT COUNT(1) AS cnt, COALESCE(SUM(amount_cents),0) AS sum_cents FROM expenses WHERE date BETWEEN ? AND ?`)
const statsArrivedStmt = db.prepare(`SELECT COUNT(1) AS cnt, COALESCE(SUM(amount_cents),0) AS sum_cents FROM expenses WHERE date BETWEEN ? AND ? AND arrived=1`)
const statsByTypeStmt = db.prepare(`
SELECT t.name AS type_name,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
JOIN types t ON e.type_id = t.id
WHERE e.date BETWEEN ? AND ?
GROUP BY t.id
ORDER BY sum_cents DESC, t.id ASC
`)
const statsBySubjectStmt = db.prepare(`
SELECT s.name AS subject_name,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
JOIN subjects s ON e.subject_id = s.id
WHERE e.date BETWEEN ? AND ?
GROUP BY s.id
ORDER BY sum_cents DESC, s.id ASC
`)
const statsByCostTypeStmt = db.prepare(`
SELECT cost_type,
COUNT(1) AS cnt,
COALESCE(SUM(amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN arrived=1 THEN amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN arrived=0 THEN amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses
WHERE date BETWEEN ? AND ?
GROUP BY cost_type
ORDER BY sum_cents DESC, cost_type ASC
`)
const statsMonthlyStmt = db.prepare(`
SELECT SUBSTR(e.date,1,7) AS ym,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
WHERE e.date BETWEEN ? AND ?
GROUP BY ym
ORDER BY ym ASC
`)
const ensureDefaults = () => {
const cntTypes = db.prepare(`SELECT COUNT(1) AS c FROM types`).get().c
const cntSubjects = db.prepare(`SELECT COUNT(1) AS c FROM subjects`).get().c
if (cntTypes === 0) {
;['差旅费','业务招待费','公共交通','打车','其他'].forEach(n => { try { insertTypeStmt.run(n, '个人费用') } catch {} })
logJSON('expense.defaults.types', { added: true }, 'expense')
}
if (cntSubjects === 0) {
;['深圳','上海'].forEach(n => { try { insertSubjectStmt.run(n, '个人费用') } catch {} })
logJSON('expense.defaults.subjects', { added: true }, 'expense')
}
}
ensureDefaults()
const formatBeijingDate = ts => {
const d = ts ? new Date(Number(ts)) : new Date()
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(d)
const y = parts.find(p => p.type === 'year')?.value || '1970'
const m = parts.find(p => p.type === 'month')?.value || '01'
const day = parts.find(p => p.type === 'day')?.value || '01'
return `${y}-${m}-${day}`
}
const bindRoutes = (app) => {
app.use('/api/expense', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
next();
});
app.get('/api/expense/sys-config', (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'expense.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
res.json({ ok: true, config: cfg })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/proxy/weekly', async (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'expense.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
const targetUrl = cfg.weekly_api_url;
if (!targetUrl) {
return res.status(500).json({ ok: false, error: 'no_weekly_url_configured' });
}
const response = await axios.post(targetUrl, req.body, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
});
res.json(response.data);
} catch (e) {
const errMsg = e.response && e.response.data && e.response.data.error ? e.response.data.error : (e.message || String(e));
res.status(500).json({ ok: false, error: errMsg });
}
})
app.get('/api/expense/types', (req, res) => {
try {
const rows = listTypesStmt.all()
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/types', (req, res) => {
try {
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!name) return res.status(400).json({ ok: false, error: 'empty_name' })
insertTypeStmt.run(name, cost_type)
const row = getTypeByNameStmt.get(name)
logJSON('expense.type.create', { id: row && row.id, name, cost_type }, 'expense')
res.json({ ok: true, id: row && row.id })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/types/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!id || !name) return res.status(400).json({ ok: false, error: 'bad_input' })
updateTypeStmt.run(name, cost_type, id)
logJSON('expense.type.update', { id, name, cost_type }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/types/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const cnt = countExpensesByTypeStmt.get(id).c
let deletedExpenses = 0
if (cnt > 0) {
const expRows = listExpenseIdsByTypeStmt.all(id)
for (const r of expRows) {
const atts = listAttachmentIdPathByExpenseStmt.all(r.id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
}
deletedExpenses = deleteExpensesByTypeStmt.run(id).changes || 0
}
const delType = deleteTypeStmt.run(id).changes || 0
logJSON('expense.type.delete', { id, deleted_type: delType, deleted_expenses: deletedExpenses }, 'expense')
res.json({ ok: true, deleted_expenses: deletedExpenses })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/subjects', (req, res) => {
try {
const rows = listSubjectsStmt.all()
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/subjects', (req, res) => {
try {
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!name) return res.status(400).json({ ok: false, error: 'empty_name' })
insertSubjectStmt.run(name, cost_type)
const row = getSubjectByNameStmt.get(name)
logJSON('expense.subject.create', { id: row && row.id, name, cost_type }, 'expense')
res.json({ ok: true, id: row && row.id })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/subjects/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!id || !name) return res.status(400).json({ ok: false, error: 'bad_input' })
updateSubjectStmt.run(name, cost_type, id)
logJSON('expense.subject.update', { id, name, cost_type }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/subjects/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const cnt = countExpensesBySubjectStmt.get(id).c
let deletedExpenses = 0
if (cnt > 0) {
const expRows = listExpenseIdsBySubjectStmt.all(id)
for (const r of expRows) {
const atts = listAttachmentIdPathByExpenseStmt.all(r.id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
}
deletedExpenses = deleteExpensesBySubjectStmt.run(id).changes || 0
}
const delSubject = deleteSubjectStmt.run(id).changes || 0
logJSON('expense.subject.delete', { id, deleted_subject: delSubject, deleted_expenses: deletedExpenses }, 'expense')
res.json({ ok: true, deleted_expenses: deletedExpenses })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/records', (req, res) => {
try {
const rowsRaw = listExpensesStmt.all()
const rows = rowsRaw.map(r => ({
id: r.id,
date: r.date,
cost_type: r.cost_type || '个人费用',
type: r.type_name || '',
amount: Number(r.amount_cents || 0) / 100,
subject: r.subject_name || '',
content: r.content || '',
arrived: !!r.arrived
}))
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/records', (req, res) => {
try {
const body = req.body || {}
const date = String(body.date || '').trim()
const typeName = String(body.type || '').trim()
const subjectName = String(body.subject || '').trim()
const content = String(body.content || '').trim()
const arrived = body.arrived ? 1 : 0
const cost_type = String(body.cost_type || '个人费用').trim()
const amountNum = parseFloat(String(body.amount || '0'))
const amount_cents = Math.round((Number.isFinite(amountNum) ? amountNum : 0) * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t && typeName) { try { insertTypeStmt.run(typeName) } catch {} ; t = getTypeByNameStmt.get(typeName) }
if (!s && subjectName) { try { insertSubjectStmt.run(subjectName) } catch {} ; s = getSubjectByNameStmt.get(subjectName) }
logJSON('expense.record.create.try', { typeName, subjectName, t_exists: !!t, s_exists: !!s }, 'expense')
if (!t || !s) return res.status(400).json({ ok: false, error: 'bad_type_or_subject' })
const info = insertExpenseStmt.run({ date, type_id: t.id, subject_id: s.id, amount_cents, content, arrived, cost_type })
logJSON('expense.record.create', { id: info.lastInsertRowid, date, type: typeName, subject: subjectName, amount_cents }, 'expense')
res.json({ ok: true, id: info.lastInsertRowid })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/rational-lock/push', (req, res) => {
try {
const body = req.body || {}
const amountNum = parseFloat(String(body.amount || '0'))
const amount = Number.isFinite(amountNum) ? amountNum : NaN
const content = String(body.content_html || '').trim()
if (!Number.isFinite(amount) || amount < 0 || !content) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const dateRaw = String(body.date || '').trim()
const date = /^\d{4}-\d{2}-\d{2}$/.test(dateRaw) ? dateRaw : formatBeijingDate(body.purchased_at)
const cost_type = '家庭费用'
const typeName = '其他家庭费用'
const subjectName = '其他家庭费用'
const amount_cents = Math.round(amount * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t) {
try { insertTypeStmt.run(typeName, cost_type) } catch {}
t = getTypeByNameStmt.get(typeName)
}
if (!s) {
try { insertSubjectStmt.run(subjectName, cost_type) } catch {}
s = getSubjectByNameStmt.get(subjectName)
}
if (t && t.cost_type !== cost_type) {
updateTypeStmt.run(typeName, cost_type, t.id)
}
if (s && s.cost_type !== cost_type) {
updateSubjectStmt.run(subjectName, cost_type, s.id)
}
if (!t || !s) return res.status(400).json({ ok: false, error: 'bad_type_or_subject' })
const info = insertExpenseStmt.run({
date,
type_id: t.id,
subject_id: s.id,
amount_cents,
content,
arrived: 1,
cost_type
})
logJSON('expense.rational_lock.push', { id: info.lastInsertRowid, date, amount_cents }, 'expense')
res.json({ ok: true, id: info.lastInsertRowid })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/records/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const body = req.body || {}
const date = String(body.date || '').trim()
const typeName = String(body.type || '').trim()
const subjectName = String(body.subject || '').trim()
const content = String(body.content || '').trim()
const arrived = body.arrived ? 1 : 0
const cost_type = String(body.cost_type || '个人费用').trim()
const amountNum = parseFloat(String(body.amount || '0'))
const amount_cents = Math.round((Number.isFinite(amountNum) ? amountNum : 0) * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t && typeName) { try { insertTypeStmt.run(typeName) } catch {} ; t = getTypeByNameStmt.get(typeName) }
if (!s && subjectName) { try { insertSubjectStmt.run(subjectName) } catch {} ; s = getSubjectByNameStmt.get(subjectName) }
logJSON('expense.record.update.try', { id, typeName, subjectName, t_exists: !!t, s_exists: !!s }, 'expense')
if (!id || !t || !s) return res.status(400).json({ ok: false, error: 'bad_input' })
updateExpenseStmt.run({ id, date, type_id: t.id, subject_id: s.id, amount_cents, content, arrived, cost_type })
logJSON('expense.record.update', { id, date, type: typeName, subject: subjectName, amount_cents }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/records/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const atts = listAttachmentIdPathByExpenseStmt.all(id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
deleteExpenseStmt.run(id)
logJSON('expense.record.delete', { id }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Attachments APIs (independent upload code & folder)
app.get('/api/expense/attachments/:expense_id', (req, res) => {
try {
const expenseId = parseInt(req.params.expense_id, 10)
if (!expenseId) return res.status(400).json({ ok: false, error: 'bad_input' })
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
const rows = listAttachmentsByExpenseStmt.all(expenseId).map(r => ({
id: r.id,
expense_id: r.expense_id,
filename: r.filename,
size_bytes: r.size_bytes,
mime: r.mime || '',
url: `/uploads/${r.path.replace(/^\\+|^\/+/, '')}`
}))
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/attachments/chunk', (req, res) => {
try {
const b = req.body || {}
const file_key = String(b.file_key || '').trim()
const chunk_index = parseInt(b.chunk_index, 10)
const total_chunks = parseInt(b.total_chunks, 10)
const data_base64 = String(b.data_base64 || '')
if (!file_key || !Number.isFinite(chunk_index) || !Number.isFinite(total_chunks) || !data_base64) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const dir = path.join(chunksDir, file_key)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const fname = String(chunk_index).padStart(6, '0') + '.bin'
const out = path.join(dir, fname)
const buf = Buffer.from(data_base64, 'base64')
fs.writeFileSync(out, buf)
logJSON('expense.attach.chunk', { file_key, chunk_index, total_chunks, bytes: buf.length }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/attachments/commit', (req, res) => {
try {
const b = req.body || {}
const file_key = String(b.file_key || '').trim()
const filenameRaw = String(b.filename || '').trim()
const size = parseInt(b.size, 10) || 0
const mime = String(b.mime || '')
const total_chunks = parseInt(b.total_chunks, 10)
const expense_id = parseInt(b.expense_id, 10)
if (!file_key || !filenameRaw || !Number.isFinite(total_chunks) || !expense_id) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const safeName = filenameRaw.replace(/[^a-zA-Z0-9._-]/g, '_')
const ts = Date.now()
const rnd = Math.random().toString(36).slice(2, 8)
const finalRel = path.join('expense', 'attachments', `${ts}-${rnd}-${safeName}`)
const finalAbs = path.join(process.cwd(), 'uploads', finalRel)
const chunksPath = path.join(chunksDir, file_key)
const files = []
for (let i = 0; i < total_chunks; i++) {
files.push(path.join(chunksPath, String(i).padStart(6, '0') + '.bin'))
}
// Assemble
if (!fs.existsSync(path.dirname(finalAbs))) fs.mkdirSync(path.dirname(finalAbs), { recursive: true })
try { fs.unlinkSync(finalAbs) } catch {}
let bytes = 0
for (const f of files) {
const buf = fs.readFileSync(f)
fs.appendFileSync(finalAbs, buf)
bytes += buf.length
}
// Cleanup chunks
try {
const list = fs.readdirSync(chunksPath)
for (const f of list) fs.unlinkSync(path.join(chunksPath, f))
fs.rmdirSync(chunksPath)
} catch {}
const info = insertAttachmentStmt.run({ expense_id, filename: filenameRaw, path: finalRel.replace(/\\/g, '/'), size_bytes: (size || bytes), mime })
const id = info.lastInsertRowid
logJSON('expense.attach.commit', { id, expense_id, filename: filenameRaw, bytes }, 'expense')
res.json({ ok: true, id, url: `/uploads/${finalRel.replace(/\\/g, '/')}` })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/attachments/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const row = getAttachmentByIdStmt.get(id)
if (row && row.path) {
const abs = path.join(process.cwd(), 'uploads', String(row.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(id)
logJSON('expense.attach.delete', { id }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Stats APIs
const computeRange = (startRaw, endRaw) => {
const pad = n => String(n).padStart(2,'0')
const today = new Date()
const startDefault = new Date(today.getFullYear(), today.getMonth(), 1)
const endDefault = new Date(today.getFullYear(), today.getMonth()+1, 0)
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`
const start = String(startRaw || '').trim() || fmt(startDefault)
const end = String(endRaw || '').trim() || fmt(endDefault)
return { start, end }
}
app.get('/api/expense/stats/summary', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const t = statsTotalStmt.get(start, end)
const a = statsArrivedStmt.get(start, end)
const total_amount = Number(t.sum_cents || 0) / 100
const arrived_amount = Number(a.sum_cents || 0) / 100
const pending_amount = total_amount - arrived_amount
const result = {
start, end,
total_count: t.cnt || 0,
total_amount,
arrived_count: a.cnt || 0,
arrived_amount,
pending_count: (t.cnt || 0) - (a.cnt || 0),
pending_amount
}
res.json({ ok: true, summary: result })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-type', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsByTypeStmt.all(start, end).map(r => ({
type: r.type_name || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-subject', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsBySubjectStmt.all(start, end).map(r => ({
subject: r.subject_name || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-cost-type', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsByCostTypeStmt.all(start, end).map(r => ({
cost_type: r.cost_type || '个人费用',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/monthly', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsMonthlyStmt.all(start, end).map(r => ({
month: r.ym || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Export CSV for records
const csvEscape = v => {
const s = String(v == null ? '' : v)
const t = s.replace(/"/g, '""')
return `"${t}"`
}
app.get('/api/expense/export.csv', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = db.prepare(`
SELECT e.date, e.cost_type, t.name AS type_name, s.name AS subject_name, e.amount_cents, e.content, e.arrived
FROM expenses e
LEFT JOIN types t ON e.type_id = t.id
LEFT JOIN subjects s ON e.subject_id = s.id
WHERE e.date BETWEEN ? AND ?
ORDER BY e.date ASC, e.id ASC
`).all(start, end)
const header = '\ufeff日期,费用类型,报销类型,报销金额(元),报销主体,报销内容,是否到账\n'
const body = rows.map(r => [
csvEscape(r.date || ''),
csvEscape(r.cost_type || '个人费用'),
csvEscape(r.type_name || ''),
csvEscape((Number(r.amount_cents || 0) / 100).toFixed(2)),
csvEscape(r.subject_name || ''),
csvEscape(String(r.content || '').replace(/\r?\n/g, ' ')),
csvEscape(r.arrived ? '已到账' : '未到账')
].join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="expense_records.csv"')
res.send(header + body)
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Export CSV for stats
app.get('/api/expense/stats/export.csv', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const t = statsTotalStmt.get(start, end)
const a = statsArrivedStmt.get(start, end)
const summaryLines = [
['期间', `${start} ~ ${end}`],
['总金额(元)', (Number(t.sum_cents || 0)/100).toFixed(2)],
['总记录数', t.cnt || 0],
['已到账金额(元)', (Number(a.sum_cents || 0)/100).toFixed(2)],
['已到账记录数', a.cnt || 0],
['未到账金额(元)', ((Number(t.sum_cents||0)-Number(a.sum_cents||0))/100).toFixed(2)],
['未到账记录数', ((t.cnt||0)-(a.cnt||0))]
]
const byType = statsByTypeStmt.all(start, end)
const bySubject = statsBySubjectStmt.all(start, end)
const byCostType = statsByCostTypeStmt.all(start, end)
const monthly = statsMonthlyStmt.all(start, end)
const toCsvBlock = (title, headerArr, rowsArr) => {
const head = headerArr.map(csvEscape).join(',')
const body = rowsArr.map(r => r.map(csvEscape).join(',')).join('\n')
return `${csvEscape(title)}\n${head}\n${body}\n`
}
const summaryCsv = toCsvBlock('汇总', ['指标','数值'], summaryLines)
const costTypeCsv = toCsvBlock('按费用类别', ['类别','金额(元)','已到账(元)','未到账(元)','记录数'],
byCostType.map(r => [r.cost_type||'个人费用', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const typeCsv = toCsvBlock('按类型', ['类型','金额(元)','已到账(元)','未到账(元)','记录数'],
byType.map(r => [r.type_name||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const subjectCsv = toCsvBlock('按主体', ['主体','金额(元)','已到账(元)','未到账(元)','记录数'],
bySubject.map(r => [r.subject_name||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const monthlyCsv = toCsvBlock('月度', ['月份','金额(元)','已到账(元)','未到账(元)','记录数'],
monthly.map(r => [r.ym||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const content = '\ufeff' + [summaryCsv, costTypeCsv, typeCsv, subjectCsv, monthlyCsv].join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="expense_stats.csv"')
res.send(content)
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
+875
View File
@@ -0,0 +1,875 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const axios = require('axios');
const XLSX = require('xlsx');
let db = null;
function initDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
unit TEXT NOT NULL,
weekly_goal INTEGER DEFAULT 0
)
`);
try {
const cols = db.prepare("PRAGMA table_info(exercise_types)").all();
if (!cols.some(c => c.name === 'sort_order')) {
db.exec("ALTER TABLE exercise_types ADD COLUMN sort_order INTEGER DEFAULT 0");
}
} catch (e) {}
db.exec(`
CREATE TABLE IF NOT EXISTS daily_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN DEFAULT 1
)
`);
try {
const cols = db.prepare("PRAGMA table_info(daily_plans)").all();
if (!cols.some(c => c.name === 'is_active')) {
db.exec("ALTER TABLE daily_plans ADD COLUMN is_active BOOLEAN DEFAULT 1");
}
// Normalize NULL values to active
db.exec("UPDATE daily_plans SET is_active = 1 WHERE is_active IS NULL");
} catch (e) {}
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
calories INTEGER NOT NULL DEFAULT 0,
exercise_duration INTEGER NOT NULL DEFAULT 0,
stand_count INTEGER NOT NULL DEFAULT 0,
daily_plan_id INTEGER,
diastolic_bp INTEGER,
systolic_bp INTEGER,
heart_rate INTEGER,
waist_circ INTEGER,
weight_kg REAL,
created_time TEXT NOT NULL,
FOREIGN KEY (daily_plan_id) REFERENCES daily_plans(id)
)
`);
try {
const cols = db.prepare("PRAGMA table_info(exercise_records)").all();
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('diastolic_bp')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN diastolic_bp INTEGER");
}
if (!colNames.has('systolic_bp')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN systolic_bp INTEGER");
}
if (!colNames.has('waist_circ')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN waist_circ INTEGER");
}
if (!colNames.has('heart_rate')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN heart_rate INTEGER");
}
if (!colNames.has('weight_kg')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN weight_kg REAL");
}
} catch (e) {
console.error('[FitnessDiary] Migration error:', e.message);
}
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL,
exercise_type_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
unit TEXT NOT NULL,
FOREIGN KEY (record_id) REFERENCES exercise_records(id) ON DELETE CASCADE,
FOREIGN KEY (exercise_type_id) REFERENCES exercise_types(id)
)
`);
// Performance indexes
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_date ON exercise_records(record_date)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_record ON exercise_items(record_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_type ON exercise_items(exercise_type_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_plan ON exercise_records(daily_plan_id)`);
} catch (e) {}
// Seed or import data for daily_plans/exercise_types when empty
try {
const typeCount = db.prepare('SELECT COUNT(1) AS c FROM exercise_types').get().c || 0;
const planCount = db.prepare('SELECT COUNT(1) AS c FROM daily_plans').get().c || 0;
if (planCount === 0) {
// Try import from old DB first
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
let imported = false;
try {
if (fs.existsSync(oldPath)) {
const OldDB = require('better-sqlite3');
const old = new OldDB(oldPath);
const oldPlans = old.prepare('SELECT id, name FROM daily_plans').all();
if (Array.isArray(oldPlans) && oldPlans.length) {
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
for (const p of oldPlans) {
stmt.run(String(p.name || '').trim(), '', 1);
}
imported = true;
}
}
} catch {}
if (!imported) {
// Fallback seed defaults
const defaults = ['正常训练', '轻断食', '跑步日', '力量日', '恢复日', '高强运动日'];
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
defaults.forEach(n => stmt.run(n, '', 1));
}
}
if (typeCount === 0) {
// Try import exercise_types from old DB
try {
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
if (fs.existsSync(oldPath)) {
const OldDB = require('better-sqlite3');
const old = new OldDB(oldPath);
const oldTypes = old.prepare('SELECT id, name, unit, weekly_goal FROM exercise_types').all();
if (Array.isArray(oldTypes) && oldTypes.length) {
const stmt = db.prepare('INSERT OR IGNORE INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)');
let sort = 0;
for (const t of oldTypes) {
stmt.run(String(t.name || '').trim(), String(t.unit || '').trim(), parseInt(t.weekly_goal || 0), sort++);
}
}
}
} catch {}
}
} catch {}
}
function getDb() {
return db;
}
// Helper: Push to Weekly
async function pushToWeekly(recordId) {
try {
const record = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.id = ?
`).get(recordId);
if (!record || record.calories <= 100) {
if (record && record.calories <= 100) console.log(`[FitnessDiary] Skipping push to weekly: calories ${record.calories} <= 100`);
return;
}
const dateStr = String(record.record_date || '').slice(0, 10);
if (!dateStr) return;
const records = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.record_date = ?
ORDER BY r.id
`).all(dateStr);
if (!Array.isArray(records) || !records.length) return;
const ids = records.map(r => r.id);
let itemsByRecord = {};
if (ids.length) {
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT i.record_id, i.quantity, i.unit, t.name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id IN (${placeholders})
`).all(...ids);
for (const it of rows) {
const rid = it.record_id;
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
itemsByRecord[rid].push(it);
}
}
const escapeHtml = s => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
let totalCalories = 0;
let totalDuration = 0;
let totalStand = 0;
let html = '<table border="1" cellspacing="0" cellpadding="4"><thead><tr><th>日期</th><th>计划</th><th>项目</th><th>数量</th><th>单位</th></tr></thead><tbody>';
for (const r of records) {
const list = itemsByRecord[r.id] || [];
if (!list.length) {
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td></td><td></td><td></td></tr>`;
} else {
for (const it of list) {
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td>${escapeHtml(it.name || '')}</td><td>${Number(it.quantity || 0)}</td><td>${escapeHtml(it.unit || '')}</td></tr>`;
}
}
totalCalories += Number(r.calories || 0);
totalDuration += Number(r.exercise_duration || 0);
totalStand += Number(r.stand_count || 0);
}
html += '</tbody></table>';
const payload = {
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
external_id: String(recordId),
date: dateStr,
items: html,
calories: totalCalories,
duration: totalDuration
};
// Assuming local server is running on process.env.PORT or default 8976
const port = process.env.PORT || 8976;
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
console.log(`[FitnessDiary] Pushing to weekly: ${url}`, JSON.stringify(payload));
await axios.post(url, payload);
console.log('[FitnessDiary] Push successful');
} catch (err) {
console.error('[FitnessDiary] Failed to push to weekly:', err.message);
}
}
async function deleteFromWeekly(recordId) {
try {
const port = process.env.PORT || 8976;
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
const payload = {
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
external_id: String(recordId),
action: 'delete'
};
console.log(`[FitnessDiary] Deleting from weekly: ${url}`, JSON.stringify(payload));
await axios.post(url, payload);
console.log('[FitnessDiary] Delete push successful');
} catch (err) {
console.error('[FitnessDiary] Failed to delete from weekly:', err.message);
}
}
// Auth Helper - DEPRECATED: Auth handled by main server middleware
// const NAV_AUTH_COOKIE = 'nav_gate';
// const parseCookie = ...
// const checkAuth = ...
function bindRoutes(app) {
const API = '/api/fitness_diary';
// Disable caching for all fitness_diary API responses
app.use(API, (req, res, next) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.set('Surrogate-Control', 'no-store');
} catch {}
next();
});
// Get all exercise types
app.get(`${API}/types`, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, name ASC').all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Get all daily plans
app.get(`${API}/daily_plans`, (req, res) => {
try {
let hasActive = false;
try {
const cols = db.prepare('PRAGMA table_info(daily_plans)').all();
hasActive = cols.some(c => c.name === 'is_active');
} catch {}
const sql = hasActive ? 'SELECT * FROM daily_plans WHERE is_active = 1 ORDER BY id' : 'SELECT * FROM daily_plans ORDER BY id';
const rows = db.prepare(sql).all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Get records (with optional date filter)
app.get(`${API}/records`, (req, res) => {
try {
const { start, end } = req.query;
// console.log(`[FitnessDiary] Get records: start=${start}, end=${end}`);
let sql = `
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
`;
const params = [];
if (start && end) {
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
params.push(start, end);
}
sql += ` ORDER BY r.record_date DESC`;
const records = db.prepare(sql).all(...params);
// console.log(`[FitnessDiary] Found ${records.length} records`);
const ids = records.map(r => r.id);
let itemsByRecord = {};
if (ids.length) {
const placeholders = ids.map(()=>'?').join(',');
const items = db.prepare(`
SELECT i.*, t.name as type_name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id IN (${placeholders})
`).all(...ids);
for (const it of items) {
const rid = it.record_id;
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
itemsByRecord[rid].push(it);
}
}
for (const rec of records) {
rec.items = itemsByRecord[rec.id] || [];
}
res.json({ ok: true, data: records });
} catch (e) {
console.error('[FitnessDiary] Get records error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// Add new record
app.post(`${API}/records`, async (req, res) => {
try {
const {
record_date,
calories,
exercise_duration,
stand_count,
daily_plan_id,
diastolic_bp,
systolic_bp,
heart_rate,
waist_circ,
weight_kg,
items
} = req.body;
// 1. Check duplicate for same day
const existing = db.prepare('SELECT id FROM exercise_records WHERE record_date = ?').get(record_date);
if (existing) {
return res.json({ ok: false, error: '该日期已存在记录,请勿重复添加' });
}
// 2. Insert Record
const insertRecord = db.prepare(`
INSERT INTO exercise_records (record_date, calories, exercise_duration, stand_count, daily_plan_id, diastolic_bp, systolic_bp, heart_rate, waist_circ, weight_kg, created_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))
`);
let planId = null;
if (daily_plan_id !== undefined && daily_plan_id !== null && daily_plan_id !== '') {
const n = parseInt(daily_plan_id, 10);
if (!Number.isNaN(n) && n > 0) planId = n;
}
const getIntOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseInt(val, 10);
return (!Number.isNaN(n) && n > 0) ? n : null;
};
const getRealOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseFloat(val);
return (!Number.isNaN(n)) ? n : null;
};
const info = insertRecord.run(
record_date,
calories,
exercise_duration,
stand_count,
planId,
getIntOrNull(diastolic_bp),
getIntOrNull(systolic_bp),
getIntOrNull(heart_rate),
getIntOrNull(waist_circ),
getRealOrNull(weight_kg)
);
const recordId = info.lastInsertRowid;
// 3. Insert Items
const insertItem = db.prepare(`
INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit)
VALUES (?, ?, ?, ?)
`);
if (Array.isArray(items)) {
for (const it of items) {
insertItem.run(recordId, it.exercise_type_id, it.quantity, it.unit);
}
}
// 4. Async Push to Weekly
const pushResult = await pushToWeekly(recordId);
res.json({ ok: true, id: recordId, push: pushResult });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete record
app.delete(`${API}/records/:id`, async (req, res) => {
try {
const { id } = req.params;
db.prepare('DELETE FROM exercise_records WHERE id = ?').run(id);
// Items cascade delete automatically
try { await deleteFromWeekly(id); } catch {}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Record detail
app.get(`${API}/record/:id`, (req, res) => {
try {
const { id } = req.params;
const r = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.id = ?
`).get(id);
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
const items = db.prepare(`
SELECT i.id, i.exercise_type_id, i.quantity, i.unit, t.name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id = ?
`).all(id);
res.json({
id: r.id,
record_date: r.record_date,
calories: r.calories,
exercise_duration: r.exercise_duration,
stand_count: r.stand_count,
diastolic_bp: r.diastolic_bp,
systolic_bp: r.systolic_bp,
heart_rate: r.heart_rate,
waist_circ: r.waist_circ,
weight_kg: r.weight_kg,
daily_plan: r.plan_name || '',
daily_plan_id: r.daily_plan_id,
items
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update record
app.put(`${API}/records/:id`, async (req, res) => {
try {
const { id } = req.params;
const body = req.body || {};
const r = db.prepare(`SELECT * FROM exercise_records WHERE id = ?`).get(id);
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
const recordDate = new Date(String(r.record_date).slice(0,10)+'T00:00:00');
const diffDays = Math.floor((Date.now() - recordDate.getTime())/86400000);
if (diffDays > 31) return res.status(400).json({ ok: false, error: '超过一个月的历史日期不允许修改' });
const getRealOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseFloat(val);
return (!Number.isNaN(n)) ? n : null;
};
db.prepare(`
UPDATE exercise_records
SET daily_plan_id = ?, calories = ?, exercise_duration = ?, stand_count = ?, diastolic_bp = ?, systolic_bp = ?, heart_rate = ?, waist_circ = ?, weight_kg = ?
WHERE id = ?
`).run((() => {
const v = body.daily_plan_id;
if (v === undefined || v === null || v === '') return null;
const n = parseInt(v, 10);
return (!Number.isNaN(n) && n > 0) ? n : null;
})(),
parseInt(body.calories||0),
parseInt(body.exercise_duration||0),
parseInt(body.stand_count||0),
body.diastolic_bp ? parseInt(body.diastolic_bp, 10) : null,
body.systolic_bp ? parseInt(body.systolic_bp, 10) : null,
body.heart_rate ? parseInt(body.heart_rate, 10) : null,
body.waist_circ ? parseInt(body.waist_circ, 10) : null,
getRealOrNull(body.weight_kg),
id);
db.prepare(`DELETE FROM exercise_items WHERE record_id = ?`).run(id);
if (Array.isArray(body.items)) {
const stmt = db.prepare(`INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit) VALUES (?, ?, ?, ?)`);
for (const it of body.items) {
stmt.run(id, it.exercise_type_id, parseInt(it.quantity||0), String(it.unit||''));
}
}
const pushResult = await pushToWeekly(id);
res.json({ ok: true, push: pushResult });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Stats (Sum for range)
app.get(`${API}/stats`, (req, res) => {
try {
const { start, end } = req.query;
let sql = `
SELECT
SUM(calories) as sum_calories,
SUM(exercise_duration) as sum_duration,
SUM(stand_count) as sum_stand
FROM exercise_records
`;
const params = [];
if (start && end) {
sql += ` WHERE record_date >= ? AND record_date <= ?`;
params.push(start, end);
}
const stats = db.prepare(sql).get(...params);
res.json({
ok: true,
stats: {
sum_calories: stats.sum_calories || 0,
sum_duration: stats.sum_duration || 0,
sum_stand: stats.sum_stand || 0
}
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Weekly Progress (for progress bars)
app.get(`${API}/weekly_progress`, (req, res) => {
try {
const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const baseDateStr = (req.query.date ? String(req.query.date).slice(0, 10) : fmt(new Date()));
const d = new Date(baseDateStr + 'T00:00:00');
const day = d.getDay() || 7;
const start = new Date(d);
start.setDate(d.getDate() - day + 1);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const sStr = fmt(start);
const eStr = fmt(end);
// Get all types and goals
const types = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, id ASC').all();
const progress = {};
types.forEach(t => {
progress[t.id] = { ...t, done: 0 };
});
// Sum up records in range
const records = db.prepare(`
SELECT i.exercise_type_id, i.quantity
FROM exercise_items i
JOIN exercise_records r ON i.record_id = r.id
WHERE r.record_date >= ? AND r.record_date <= ?
`).all(sStr, eStr);
records.forEach(r => {
if (progress[r.exercise_type_id]) {
progress[r.exercise_type_id].done += r.quantity;
}
});
const result = Object.values(progress).map(p => ({
type_id: p.id,
name: p.name,
unit: p.unit,
weekly_goal: p.weekly_goal,
done: p.done,
remaining: Math.max(0, p.weekly_goal - p.done)
}));
res.json({ ok: true, week_start: sStr, week_end: eStr, progress: result });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update types
app.post(`${API}/types/update`, (req, res) => {
try {
const body = req.body || {};
const arr = Array.isArray(body.types) ? body.types : [];
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
const dup = names.filter((n, i) => names.indexOf(n) !== i);
if (dup.length) return res.status(400).json({ ok: false, error: `项目名称重复:${Array.from(new Set(dup)).join('、')}` });
const update = db.prepare(`UPDATE exercise_types SET name = ?, unit = ?, weekly_goal = ?, sort_order = ? WHERE id = ?`);
const checkUnique = db.prepare(`SELECT id FROM exercise_types WHERE name = ? AND id != ?`);
for (const t of arr) {
const name = String(t.name||'').trim();
const unit = String(t.unit||'').trim();
const goal = parseInt(t.weekly_goal||0);
const sort = parseInt(t.sort_order||0);
const id = parseInt(t.id||0);
if (!id || !name || !unit) continue;
const exists = checkUnique.get(name, id);
if (exists) return res.status(400).json({ ok: false, error: '保存失败:项目名称已存在(数据库唯一约束)。请检查并修改后再保存。' });
update.run(name, unit, goal, sort, id);
}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Add type
app.post(`${API}/types/add`, (req, res) => {
try {
const { name, unit, weekly_goal } = req.body || {};
const n = String(name||'').trim();
const u = String(unit||'').trim();
const g = parseInt(weekly_goal||0);
if (!n || !u) return res.status(400).json({ ok: false, error: '请输入项目名称和单位' });
const exists = db.prepare(`SELECT id FROM exercise_types WHERE name = ?`).get(n);
if (exists) return res.status(400).json({ ok: false, error: '该项目已存在' });
db.prepare(`INSERT INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)`).run(n, u, g, 0);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete type
app.delete(`${API}/types/:id`, (req, res) => {
try {
const id = parseInt(req.params.id || '0', 10);
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
const used = db.prepare(`SELECT COUNT(1) AS c FROM exercise_items WHERE exercise_type_id = ?`).get(id);
if (Number(used.c || 0) > 0) {
return res.status(400).json({ ok: false, error: '该项目已在记录中使用,无法删除' });
}
db.prepare(`DELETE FROM exercise_types WHERE id = ?`).run(id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update plans
app.post(`${API}/plans/update`, (req, res) => {
try {
const body = req.body || {};
const arr = Array.isArray(body.plans) ? body.plans : [];
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
const dup = names.filter((n, i) => names.indexOf(n) !== i);
if (dup.length) return res.status(400).json({ ok: false, error: `计划名称重复:${Array.from(new Set(dup)).join('、')}` });
const update = db.prepare(`UPDATE daily_plans SET name = ? WHERE id = ?`);
const checkUnique = db.prepare(`SELECT id FROM daily_plans WHERE name = ? AND id != ?`);
for (const p of arr) {
const name = String(p.name||'').trim();
const id = parseInt(p.id||0);
if (!id || !name) continue;
const exists = checkUnique.get(name, id);
if (exists) return res.status(400).json({ ok: false, error: '保存失败:计划名称已存在。' });
update.run(name, id);
}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Add plan
app.post(`${API}/plans/add`, (req, res) => {
try {
const { name } = req.body || {};
const n = String(name||'').trim();
if (!n) return res.status(400).json({ ok: false, error: '请输入计划名称' });
const exists = db.prepare(`SELECT id FROM daily_plans WHERE name = ?`).get(n);
if (exists) return res.status(400).json({ ok: false, error: '该计划已存在' });
db.prepare(`INSERT INTO daily_plans (name) VALUES (?)`).run(n);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete plan (soft delete)
app.delete(`${API}/plans/:id`, (req, res) => {
try {
const id = parseInt(req.params.id || '0', 10);
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
db.prepare(`UPDATE daily_plans SET is_active = 0 WHERE id = ?`).run(id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Clear data
app.post(`${API}/records/clear`, (req, res) => {
try {
db.prepare(`DELETE FROM exercise_items`).run();
db.prepare(`DELETE FROM exercise_records`).run();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Auto-fill missing records for previous days
app.post(`${API}/records/auto-fill-missing`, (req, res) => {
try {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
// Get all records before today, ordered by date descending
const allRecords = db.prepare(`
SELECT id, record_date, calories, exercise_duration, stand_count
FROM exercise_records
WHERE record_date < ?
ORDER BY record_date DESC
`).all(todayStr);
if (!allRecords || allRecords.length === 0) {
return res.json({ ok: true, filled: 0, message: '无历史空缺记录' });
}
// Group records by date (in case of multiple records per day)
const recordsByDate = {};
allRecords.forEach(r => {
const dateStr = String(r.record_date).slice(0, 10);
if (!recordsByDate[dateStr]) {
recordsByDate[dateStr] = [];
}
recordsByDate[dateStr].push(r);
});
const filledDates = [];
const updateStmt = db.prepare(`
UPDATE exercise_records
SET calories = ?, exercise_duration = ?, stand_count = ?
WHERE id = ?
`);
// Check each date before today
Object.keys(recordsByDate).sort().forEach(dateStr => {
const records = recordsByDate[dateStr];
// Check if any record on this date has missing fields
records.forEach(record => {
const needsFill = (record.calories === 0 || record.calories === null) ||
(record.exercise_duration === 0 || record.exercise_duration === null) ||
(record.stand_count === 0 || record.stand_count === null);
if (needsFill) {
const newCalories = (record.calories === 0 || record.calories === null) ? 330 : record.calories;
const newDuration = (record.exercise_duration === 0 || record.exercise_duration === null) ? 45 : record.exercise_duration;
const newStandCount = (record.stand_count === 0 || record.stand_count === null) ? 12 : record.stand_count;
updateStmt.run(newCalories, newDuration, newStandCount, record.id);
if (!filledDates.includes(dateStr)) {
filledDates.push(dateStr);
}
}
});
});
const filledCount = filledDates.length;
const message = filledCount > 0
? `已自动补填 ${filledCount} 天空缺记录:${filledDates.join('、')}`
: '无空缺记录需要补填';
console.log(`[FitnessDiary] Auto-fill missing: ${message}`);
res.json({ ok: true, filled: filledCount, dates: filledDates, message });
} catch (e) {
console.error('[FitnessDiary] Auto-fill error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// Export Excel
app.get(`${API}/export`, (req, res) => {
try {
const { start, end } = req.query;
let sql = `SELECT r.*, p.name as plan_name FROM exercise_records r LEFT JOIN daily_plans p ON r.daily_plan_id = p.id`;
const params = [];
if (start && end) {
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
params.push(start, end);
}
sql += ` ORDER BY r.record_date ASC`;
const records = db.prepare(sql).all(...params);
const items = [];
for (const r of records) {
const its = db.prepare(`
SELECT i.*, t.name as type_name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id = ?
`).all(r.id);
its.forEach(it => items.push({
id: it.id, record_id: r.id, date: r.record_date, exercise_type: it.type_name || '', quantity: it.quantity, unit: it.unit
}));
}
const ws1 = XLSX.utils.json_to_sheet(records.map(r => ({
id: r.id,
record_date: r.record_date,
daily_plan: r.plan_name || '',
calories: r.calories,
exercise_duration: r.exercise_duration,
stand_count: r.stand_count,
created_time: r.created_time
})));
const ws2 = XLSX.utils.json_to_sheet(items);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws1, 'Records');
XLSX.utils.book_append_sheet(wb, ws2, 'Items');
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
const startName = (start || '').replace(/-/g, '') || 'start';
const endName = (end || '').replace(/-/g, '') || 'end';
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="export_${startName}_${endName}.xlsx"`);
res.send(buf);
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
}
module.exports = {
initDb,
getDb,
bindRoutes
};
+221
View File
@@ -0,0 +1,221 @@
const { db } = require('./db')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const axios = require('axios')
const cfgPath = path.join(process.cwd(), 'config', 'funds.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) {
const defaultPort = process.env.PORT || 5050
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex'), display: { currency: 'CNY' }, weekly_api_base: defaultBase, weekly_key: '', weekly_sync_enabled: true }, null, 2))
}
const getConfig = () => {
if (_cachedCfg) return _cachedCfg
_cachedCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cachedCfg
}
const setConfig = cfg => {
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
_cachedCfg = null // Clear config cache
_cachedKey = null // Clear key cache
}
let _cachedCfg = null
let _cachedKey = null
const deriveKey = () => {
if (_cachedKey) return _cachedKey
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.FUNDS_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
_cachedKey = crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
return _cachedKey
}
const enc = (plain) => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain||''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
ending_cents INTEGER NOT NULL,
bank_status_enc BLOB,
bank_status_iv BLOB,
bank_status_tag BLOB,
remark_enc BLOB,
remark_iv BLOB,
remark_tag BLOB,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
// Prepared statements (global for performance, data is still real-time)
const insertFlow = db.prepare(
`INSERT INTO fund_flows (date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag)
VALUES (@date, @type, @amount_cents, 0, @bank_status_enc, @bank_status_iv, @bank_status_tag, @remark_enc, @remark_iv, @remark_tag)`
)
const updateFlow = db.prepare(
`UPDATE fund_flows SET date=@date, type=@type, amount_cents=@amount_cents, bank_status_enc=@bank_status_enc, bank_status_iv=@bank_status_iv, bank_status_tag=@bank_status_tag, remark_enc=@remark_enc, remark_iv=@remark_iv, remark_tag=@remark_tag, updated_at=datetime('now') WHERE id=@id`
)
const deleteFlowStmt = db.prepare(`DELETE FROM fund_flows WHERE id = ?`)
const listFlowsStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows ORDER BY date ASC, id ASC`)
const listRangeStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date>=? AND date<=? ORDER BY date ASC, id ASC`)
const listByDateStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date=? ORDER BY id ASC`)
const sumByDateStmt = db.prepare(`SELECT SUM(amount_cents) AS s FROM fund_flows WHERE date=?`)
const updateEndingStmt = db.prepare(`UPDATE fund_flows SET ending_cents=? WHERE id=?`)
const recalcEndingFrom = db.transaction((startDate) => {
const all = listFlowsStmt.all()
let balance = 0
for (const r of all) {
if (startDate) {
// find starting balance: sum of amounts before startDate
if (r.date < startDate) { balance += (r.amount_cents||0); continue }
}
balance += (r.amount_cents||0)
updateEndingStmt.run(balance, r.id)
}
})
const listFlows = (start, end, type, kw) => {
const rows = (start && end) ? listRangeStmt.all(start, end) : listFlowsStmt.all()
const mapRow = r => {
const bank = r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : ''
const remark = r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
return { id: r.id, date: r.date, type: r.type, amount_cents: r.amount_cents, ending_cents: r.ending_cents, bank_status: bank, remark }
}
let filtered = rows.map(mapRow)
if (type && type !== 'all') filtered = filtered.filter(r => r.type === type)
if (kw) { const reg = new RegExp(String(kw).trim(), 'i'); filtered = filtered.filter(r => reg.test(r.bank_status||'') || reg.test(r.remark||'')) }
return filtered
}
const formatCNY = cents => {
try { return (Number(cents||0) / 100).toFixed(2) } catch { return '0.00' }
}
const brief = s => { try { return String(s||'').replace(/\s+/g,' ').slice(0,500) } catch { return '' } }
const getWeeklyCfg = () => {
const cfg = getConfig()
const port = process.env.PORT || 5050
const base = cfg.weekly_api_base || `http://localhost:${port}/api/weekly`
const key = String(cfg.weekly_key || process.env.SYSTEM_AUTH_PASS_93220 || '')
const enabled = cfg.weekly_sync_enabled !== false
return { base, key, enabled }
}
const pushWeeklyDaily = async (date) => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
const total = (sumByDateStmt.get(date).s || 0)
const title = `本金变动池 ${date}(净变动:${formatCNY(total)})`
const items = rows.map((r, idx) => ({
idx: idx+1,
type: r.type,
amount: formatCNY(r.amount_cents),
bank: r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : '',
remark: r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
}))
const trs = items.map(it => `<tr><td>${it.idx}</td><td>${it.type==='in'?'汇入':'汇出'}</td><td>${it.amount}</td><td>${(it.bank||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td><td>${(it.remark||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td></tr>`).join('')
const content = `
<div>
<h4>当日净变动</h4>
<p>人民币:${formatCNY(total)}</p>
<h4>明细</h4>
<table border="1" cellspacing="0" cellpadding="4">
<thead><tr><th>序号</th><th>类型</th><th>金额(元)</th><th>银行方/状态</th><th>备注</th></tr></thead>
<tbody>${trs}</tbody>
</table>
</div>
`.trim()
const payload = { key: api.key, source: 'funds', external_id: `funds-daily-${date}`, title, content, date, status_code: 'new', status_label: '📅本周新增' }
const url = `${api.base}/ext/event`
logJSON('funds.weekly.push.try', { date, url, base: api.base, rows: rows.length, total_cents: total, title_len: title.length, content_len: content.length }, 'funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('funds.weekly.push.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'funds')
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('funds.weekly.push.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'funds')
}
}
const deleteOrUpdateWeeklyDaily = async (date) => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
if (!rows || rows.length === 0) {
const url = `${api.base}/ext/event`
const payload = { key: api.key, source: 'funds', external_id: `funds-daily-${date}`, date, deleted: true }
logJSON('funds.weekly.delete.try', { date, url, base: api.base }, 'funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('funds.weekly.delete.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'funds')
} else {
await pushWeeklyDaily(date)
}
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('funds.weekly.delete.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'funds')
}
}
const createFlow = (payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status||'')
const rm = enc(remark||'')
insertFlow.run({ date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('funds.create', { date, type, amount_cents })
Promise.resolve().then(()=>pushWeeklyDaily(date)).catch(()=>{})
}
const updateFlowById = (id, payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status||'')
const rm = enc(remark||'')
updateFlow.run({ id, date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('funds.update', { id, date, type, amount_cents })
Promise.resolve().then(()=>pushWeeklyDaily(date)).catch(()=>{})
}
const deleteFlow = (id) => {
try {
const row = db.prepare(`SELECT date FROM fund_flows WHERE id=?`).get(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('funds.delete', { id })
if (row && row.date) Promise.resolve().then(()=>deleteOrUpdateWeeklyDaily(row.date)).catch(()=>{})
} catch (e) {
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('funds.delete', { id, error: String(e.message||e) })
}
}
module.exports = { getConfig, setConfig, listFlows, createFlow, updateFlowById, deleteFlow }
File diff suppressed because it is too large Load Diff
+6240
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
const fs = require('fs')
const path = require('path')
const iconv = require('iconv-lite')
const { logJSON } = require('./logger')
const readTextWithFallback = filePath => {
const buf = fs.readFileSync(filePath)
const attempts = [
{ name: 'utf8', text: buf.toString('utf8') },
{ name: 'gb18030', text: iconv.decode(buf, 'gb18030') },
{ name: 'gbk', text: iconv.decode(buf, 'gbk') }
]
const score = text => {
const bad = (text.match(/\uFFFD/g) || []).length
const dateCount = (text.match(/20\d{2}-\d{2}-\d{2}/g) || []).length
const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length
return dateCount * 100 + cjk - bad * 30
}
attempts.sort((a, b) => score(b.text) - score(a.text))
return { encoding: attempts[0].name, text: attempts[0].text }
}
const parseCsvLine = line => {
const cells = []
let cur = ''
let inQuote = false
for (let i = 0; i < line.length; i += 1) {
const ch = line[i]
if (ch === '"') {
if (inQuote && line[i + 1] === '"') {
cur += '"'
i += 1
} else {
inQuote = !inQuote
}
continue
}
if (ch === ',' && !inQuote) {
cells.push(cur)
cur = ''
continue
}
cur += ch
}
cells.push(cur)
return cells.map(v => String(v || '').trim())
}
const parseCsvText = text => {
const lines = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
return lines.map((line, idx) => ({ lineNo: idx + 1, cells: parseCsvLine(line) }))
}
const pickNumber = (text, patterns) => {
for (const p of patterns) {
const m = text.match(p)
if (m && m[1] !== undefined) {
const n = Number(m[1])
if (Number.isFinite(n)) return n
}
}
return null
}
const detectEventType = text => {
if (/卖出成交|买入成交|成交单|成交价|成交金额|买入成功|卖出成功/.test(text)) return 'fill'
if (/废单|取消|撤单/.test(text)) return 'cancel'
if (/预设|条件单|条件单已设|已设条件单|回补设置|回卖预设|抄底预设|设定买入|设定卖出|计划卖出|计划买入/.test(text)) return 'order_preset'
if (/同日生效|同时生效|规则生效|每周|每个|自动定投/.test(text)) return 'rule_activate'
return 'note'
}
const detectSide = (text, eventType) => {
if (/卖出成交|卖出成功|卖出成交单/.test(text)) return 'sell'
if (/买入成交|买入成功|买入成交单/.test(text)) return 'buy'
if (eventType === 'order_preset') {
if (/卖出的?条件单|卖出条件单|设定卖出|计划卖出|定投卖出|回卖/.test(text)) return 'sell'
if (/买入的?条件单|买入条件单|设定买入|计划买入|回补单|抄底单/.test(text)) return 'buy'
}
const hasSell = /卖出|止盈|抛/.test(text)
const hasBuy = /买入|回补|抄底|补仓|定投/.test(text)
if (eventType === 'fill') {
if (/回补单|条件单|预设/.test(text) && !/买入成交|买入成功|卖出成交|卖出成功/.test(text)) {
if (hasSell && !hasBuy) return 'sell'
return ''
}
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
}
if (eventType === 'order_preset') {
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
if (/卖出预设/.test(text)) return 'sell'
if (/买入预设|回补|抄底/.test(text)) return 'buy'
}
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
return ''
}
const backfillFillSide = events => {
const pending = []
for (const ev of events) {
if (ev.event_type === 'order_preset' && ev.status === 'pending' && ev.side) {
pending.push(ev)
continue
}
if (ev.event_type !== 'fill' || ev.side) continue
const idx = [...pending].reverse().findIndex(p => {
if (p.symbol !== ev.symbol) return false
if (p.strategy_id && ev.strategy_id && p.strategy_id !== ev.strategy_id) return false
if (p.qty && ev.qty && Number(p.qty) !== Number(ev.qty)) return false
return true
})
if (idx < 0) continue
const matched = pending[pending.length - 1 - idx]
ev.side = matched.side
ev.pocket = inferPocket(ev.strategy_id, ev.side, ev.note || '')
}
return events
}
const detectStatus = eventType => {
if (eventType === 'fill') return 'filled'
if (eventType === 'cancel') return 'cancelled'
if (eventType === 'order_preset') return 'pending'
return ''
}
const inferStrategyId = text => {
if (/采摘|回补|回卖/.test(text)) return 'HARVEST_LADDER_v1'
if (/网格|跌幅|自动定投/.test(text)) return 'GRID_AMBUSH_v1'
if (/现金仓|抄底/.test(text)) return 'CASH_BOTTOM_v1'
return 'MANUAL_DIARY_v1'
}
const inferPocket = (strategyId, side, text) => {
if (strategyId === 'CASH_BOTTOM_v1') return 'cash'
if (strategyId === 'GRID_AMBUSH_v1') return 'grid'
if (/现金仓/.test(text)) return 'cash'
if (side === 'sell') return 'core'
return 'swing'
}
const inferChainId = text => {
if (/第一档/.test(text)) return 'L1'
if (/第二档/.test(text)) return 'L2'
if (/第三档/.test(text)) return 'L3'
const merged = text.match(/(\d)\D{0,6}(\d)/)
if (/合并/.test(text) && merged) return `M${merged[1]}${merged[2]}`
if (/网格/.test(text)) return 'G'
return 'X'
}
const extractEventFromText = (text, context) => {
const line = String(text || '').trim()
if (!line) return null
if (!/(20\d{2}-\d{2}-\d{2}|预设|成交|回补|回卖|网格|抄底|定投|废单|取消|撤单)/.test(line)) return null
const eventType = detectEventType(line)
const side = detectSide(line, eventType)
const strategyId = inferStrategyId(`${context.sectionText} ${line}`)
const orderPrice = pickNumber(line, [
/单价[^\d-]*([0-9]+(?:\.[0-9]+)?)/,
/([0-9]+(?:\.[0-9]+)?)\s*元/
])
const fillPrice = pickNumber(line, [
/成交价[^\d-]*([0-9]+(?:\.[0-9]+)?)/,
/价格[^\d-]*([0-9]+(?:\.[0-9]+)?)/
])
const qty = pickNumber(line, [/([0-9]+(?:\.[0-9]+)?)\s*股/])
const amount = pickNumber(line, [/成交金额[^\d-]*([0-9]+(?:\.[0-9]+)?)/])
const eventDate = (line.match(/20\d{2}-\d{2}-\d{2}/) || [context.lastDate || ''])[0]
const triggerRule = (line.match(/[+-]?\d+(?:\.\d+)?%/g) || []).join('|')
const chainId = inferChainId(line)
const parentMatch = line.match(/(\d)\D{0,6}(\d)/)
const parentEventIds = /合并/.test(line) && parentMatch ? `${parentMatch[1]}+${parentMatch[2]}` : ''
return {
event_time: eventDate,
symbol: context.currentSymbol || '',
name: context.currentName || '',
strategy_id: strategyId,
pocket: inferPocket(strategyId, side, line),
chain_id: chainId,
event_type: eventType,
side,
order_price: eventType === 'order_preset' ? orderPrice : null,
fill_price: eventType === 'fill' ? (fillPrice || orderPrice) : null,
qty,
amount: eventType === 'fill' ? amount : null,
trigger_rule: triggerRule,
status: detectStatus(eventType),
parent_event_ids: parentEventIds,
note: line
}
}
const normalizeRowsToEvents = (rows, symbolFilter) => {
const events = []
let currentSymbol = ''
let currentName = ''
let sectionText = ''
let lastDate = ''
let seq = 1
for (const row of rows) {
const cells = row.cells
const c0 = String(cells[0] || '').trim()
const c1 = String(cells[1] || '').trim()
const c4 = String(cells[4] || '').trim()
const c5 = String(cells[5] || '').trim()
const c6 = String(cells[6] || '').trim()
if (/\d{6}/.test(c1)) currentSymbol = (c1.match(/\d{6}/) || [''])[0]
if (c0 && c0 !== 'ETF名称') currentName = c0
if (c4) sectionText = c4
const useSymbol = symbolFilter ? symbolFilter === currentSymbol : true
if (!useSymbol) continue
const mergedText = [c4, c5, c6].filter(Boolean).join(' ')
const pieces = mergedText.split(/\s{2,}|----+|={3,}|------------/).map(s => s.trim()).filter(Boolean)
const candidateTexts = pieces.length ? pieces : [mergedText]
for (const one of candidateTexts) {
const context = { currentSymbol, currentName, sectionText, lastDate }
const ev = extractEventFromText(one, context)
if (!ev) continue
if (ev.event_time) lastDate = ev.event_time
const id = `EV${ev.symbol || 'NA'}_${String(seq).padStart(4, '0')}`
seq += 1
events.push(Object.assign({ event_id: id, source_line_no: row.lineNo }, ev))
}
}
return backfillFillSide(events)
}
const summarizeEvents = events => {
let buy = 0
let sell = 0
let pending = 0
for (const ev of events) {
if (ev.status === 'pending') pending += 1
if (ev.event_type !== 'fill') continue
const amt = Number(ev.amount || 0)
if (ev.side === 'buy') buy += amt
if (ev.side === 'sell') sell += amt
}
return {
total_events: events.length,
pending_orders: pending,
filled_buy_amount: Number(buy.toFixed(2)),
filled_sell_amount: Number(sell.toFixed(2)),
net_cashflow: Number((sell - buy).toFixed(2))
}
}
const bindRoutes = app => {
app.use('/api/investment_ledger', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
next()
})
app.get('/api/investment_ledger/ping', (req, res) => {
res.json({ ok: true, ts: Date.now() })
})
app.post('/api/investment_ledger/parse', (req, res) => {
try {
const symbol = String((req.body && req.body.symbol) || '').trim()
const rawText = String((req.body && req.body.raw_text) || '')
const filePath = String((req.body && req.body.file_path) || '').trim()
let text = rawText
let encoding = 'utf8'
if (!text && filePath) {
const abs = path.resolve(filePath)
if (!fs.existsSync(abs)) return res.status(400).json({ ok: false, error: 'file_not_found' })
const decoded = readTextWithFallback(abs)
text = decoded.text
encoding = decoded.encoding
}
if (!text) return res.status(400).json({ ok: false, error: 'empty_input' })
const rows = parseCsvText(text)
const events = normalizeRowsToEvents(rows, symbol)
const summary = summarizeEvents(events)
logJSON('investment_ledger.parse', { symbol, encoding, events: events.length }, 'investment_ledger')
res.json({ ok: true, encoding, summary, rows: events })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
+203
View File
@@ -0,0 +1,203 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'language_behavior_lock.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE IF NOT EXISTS lbl_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lbl_sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lbl_user_data (
username TEXT PRIMARY KEY,
records_json TEXT NOT NULL DEFAULT '[]',
keywords_json TEXT NOT NULL DEFAULT '[]',
draft_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lbl_sessions_user ON lbl_sessions(username);
CREATE INDEX IF NOT EXISTS idx_lbl_sessions_exp ON lbl_sessions(expires_at);
`)
const now = () => Date.now()
const SESSION_MAX_AGE = 1 * 24 * 3600 * 1000
const COOKIE_NAME = 'language_behavior_lock_sid'
const DEFAULT_USER = { username: 'admin_lbl', password: process.env.LOCK_DEFAULT_PASSWORD || '' }
const parseCookie = cookieStr => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => {
const idx = p.indexOf('=')
if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1))
})
return out
}
const ensureDefaultUser = () => {
const row = db.prepare('SELECT username FROM lbl_users WHERE username = ?').get(DEFAULT_USER.username)
if (!row) {
db.prepare('INSERT INTO lbl_users (username, password, created_at) VALUES (?, ?, ?)').run(DEFAULT_USER.username, DEFAULT_USER.password, now())
}
}
const cleanupSessions = () => {
db.prepare('DELETE FROM lbl_sessions WHERE expires_at <= ?').run(now())
}
const readDataOf = username => {
const row = db.prepare('SELECT records_json, keywords_json, draft_json FROM lbl_user_data WHERE username = ?').get(username)
if (!row) return { db: { records: [], keywords: [] }, draft: null }
let records = []
let keywords = []
let draft = null
try { records = JSON.parse(row.records_json || '[]') } catch {}
try { keywords = JSON.parse(row.keywords_json || '[]') } catch {}
try { draft = row.draft_json ? JSON.parse(row.draft_json) : null } catch {}
return {
db: {
records: Array.isArray(records) ? records : [],
keywords: Array.isArray(keywords) ? keywords : []
},
draft: draft && typeof draft === 'object' ? draft : null
}
}
const writeDataOf = (username, data) => {
const dbData = data && data.db && typeof data.db === 'object' ? data.db : {}
const records = Array.isArray(dbData.records) ? dbData.records : []
const keywords = Array.isArray(dbData.keywords) ? dbData.keywords : []
const draft = data && data.draft && typeof data.draft === 'object' ? data.draft : null
db.prepare(`
INSERT INTO lbl_user_data (username, records_json, keywords_json, draft_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
records_json = excluded.records_json,
keywords_json = excluded.keywords_json,
draft_json = excluded.draft_json,
updated_at = excluded.updated_at
`).run(
username,
JSON.stringify(records),
JSON.stringify(keywords),
draft ? JSON.stringify(draft) : null,
now()
)
}
const getSessionUser = req => {
cleanupSessions()
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (!token) return ''
const row = db.prepare('SELECT username, expires_at FROM lbl_sessions WHERE token = ?').get(token)
if (!row) return ''
if (Number(row.expires_at) <= now()) {
db.prepare('DELETE FROM lbl_sessions WHERE token = ?').run(token)
return ''
}
return String(row.username || '')
}
const requireAuth = (req, res, next) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
req.lblUser = username
next()
}
const bindRoutes = app => {
ensureDefaultUser()
app.get('/api/language_behavior_lock/health', (req, res) => {
res.json({ ok: true })
})
app.get('/api/language_behavior_lock/auth/local_creds', (req, res) => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
})
}
} catch {}
let content = null
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
if (!content) return res.json({ ok: true, creds: null })
const creds = {}
content.split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const idx = t.indexOf('=')
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
})
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
if (serverToken) {
const clientToken = String(req.query.token || '')
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
}
const u = creds.LBL_USERNAME || ''
const p = creds.LBL_PASSWORD || ''
if (!u && !p) return res.json({ ok: true, creds: null })
res.json({ ok: true, creds: { username: u, password: p } })
} catch { res.json({ ok: true, creds: null }) }
})
app.post('/api/language_behavior_lock/auth/login', (req, res) => {
const username = String((req.body && req.body.username) || '').trim()
const password = String((req.body && req.body.password) || '')
if (!username || !password) return res.status(400).json({ ok: false, error: 'invalid_credentials' })
const row = db.prepare('SELECT username, password FROM lbl_users WHERE username = ?').get(username)
if (!row || String(row.password || '') !== password) return res.status(401).json({ ok: false, error: 'invalid_credentials' })
const token = crypto.randomBytes(24).toString('hex')
const createdAt = now()
const expiresAt = createdAt + SESSION_MAX_AGE
db.prepare('INSERT INTO lbl_sessions (token, username, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, username, createdAt, expiresAt)
res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE })
res.json({ ok: true, username })
})
app.post('/api/language_behavior_lock/auth/logout', (req, res) => {
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (token) db.prepare('DELETE FROM lbl_sessions WHERE token = ?').run(token)
res.cookie(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0 })
res.json({ ok: true })
})
app.get('/api/language_behavior_lock/auth/me', (req, res) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
res.json({ ok: true, username })
})
app.get('/api/language_behavior_lock/data', requireAuth, (req, res) => {
const payload = readDataOf(req.lblUser)
res.json({ ok: true, ...payload })
})
app.put('/api/language_behavior_lock/data', requireAuth, (req, res) => {
writeDataOf(req.lblUser, req.body || {})
res.json({ ok: true })
})
}
module.exports = { bindRoutes }
+86
View File
@@ -0,0 +1,86 @@
const fs = require('fs')
const path = require('path')
const logsDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true })
const logFile = path.join(logsDir, 'app.log')
if (!fs.existsSync(logFile)) fs.writeFileSync(logFile, '')
const channelFileCache = new Map()
let pendingWrites = new Map()
let flushScheduled = false
const ts = () => new Date().toISOString()
const ensureChannel = channel => {
const name = String(channel || '').trim()
if (!name) return null
if (channelFileCache.has(name)) return channelFileCache.get(name)
const file = path.join(logsDir, `${name}.log`)
if (!fs.existsSync(file)) fs.writeFileSync(file, '')
channelFileCache.set(name, file)
return file
}
const flushPendingWrites = () => {
flushScheduled = false
if (pendingWrites.size === 0) return
const batch = pendingWrites
pendingWrites = new Map()
batch.forEach((content, file) => {
fs.appendFile(file, content, () => {})
})
if (pendingWrites.size > 0) scheduleFlush()
}
const scheduleFlush = () => {
if (flushScheduled) return
flushScheduled = true
setImmediate(flushPendingWrites)
}
const enqueueWrite = (file, line) => {
const prev = pendingWrites.get(file) || ''
pendingWrites.set(file, prev + line)
scheduleFlush()
}
const log = (msg, channel) => {
const line = `${ts()} ${String(msg)}\n`
enqueueWrite(logFile, line)
const chFile = ensureChannel(channel)
if (chFile) enqueueWrite(chFile, line)
}
const logJSON = (label, obj, channel) => {
let s = ''
try { s = JSON.stringify(obj) } catch { s = String(obj) }
log(`${label} ${s}`, channel)
}
const getLogs = (limit = 2000, channel) => {
let file = logFile
const chFile = ensureChannel(channel)
if (chFile) file = chFile
let content = ''
try {
const stat = fs.statSync(file)
const maxReadBytes = Math.min(stat.size, 512 * 1024)
const start = Math.max(0, stat.size - maxReadBytes)
const fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(maxReadBytes)
const bytesRead = fs.readSync(fd, buf, 0, maxReadBytes, start)
fs.closeSync(fd)
content = buf.subarray(0, bytesRead).toString('utf-8')
} catch {}
const lines = content.split(/\r?\n/).filter(Boolean)
const tail = lines.slice(Math.max(0, lines.length - limit))
return tail
}
const clearLogs = channel => {
const chFile = ensureChannel(channel)
if (chFile) fs.truncateSync(chFile, 0)
else fs.truncateSync(logFile, 0)
}
module.exports = { log, logJSON, getLogs, clearLogs }
@@ -0,0 +1,39 @@
{
"COMEX黄金:ratio_max": "2026-02-19-am",
"COMEX白银:白银价格过低": "2026-02-18-pm",
"COMEX白银:白银跌幅预警": "2026-03-04-am",
"COMEX黄金:drop_2": "2026-03-03-pm",
"COMEX黄金:drop_3": "2026-03-04-am",
"COMEX白银:白银涨幅预警": "2026-03-04-pm",
"印度ETF:drop_2": "2026-03-03-pm",
"印度ETF:rise_2": "2026-02-23-pm",
"COMEX黄金:ratio_min": "2026-03-03-pm",
"恒生指数:rise_2": "2026-02-24-am",
"COMEX黄金:rise_3": "2026-03-02-pm",
"创业板指:rise_2": "2026-02-24-am",
"恒生指数:drop_2": "2026-03-04-pm",
"日经ETF(HK):rise_2": "2026-03-02-am",
"黄金白银净值跟踪:黄金_premium_normal": "2026-03-04-pm",
"COMEX白银:白银价格过高": "2026-03-02-pm",
"北证50:drop_2": "2026-03-03-pm",
"北证50:drop_3": "2026-03-03-pm",
"日经ETF(HK):drop_2": "2026-03-03-pm",
"北证50:drop_4": "2026-03-03-pm",
"日经ETF(HK):drop_3": "2026-03-03-pm",
"中国神华-299法:trend_bull": "2026-03-03-am",
"德国ETF:drop_2": "2026-03-03-am",
"德国ETF:drop_3": "2026-03-03-pm",
"科创50:drop_2": "2026-03-03-am",
"科创50:drop_3": "2026-03-03-pm",
"中国银行-299法:above_ma20": "2026-03-04-am",
"科创50:drop_4": "2026-03-03-pm",
"深证成指:drop_2": "2026-03-03-pm",
"创业板指:drop_2": "2026-03-04-am",
"科创50:drop_5": "2026-03-04-am",
"深证成指:drop_3": "2026-03-04-am",
"COMEX黄金:drop_4": "2026-03-04-am",
"COMEX黄金:drop_5": "2026-03-03-pm",
"纳斯达克:drop_2": "2026-03-04-am",
"标普500:drop_2": "2026-03-04-am",
"恒生指数:drop_3": "2026-03-04-pm"
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "巴西IBOVESPA",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWZ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWZ",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/Sao_Paulo",
"sessions": [
{
"start": "10:00",
"end": "17:30"
}
],
"display_label": "巴西IBOV"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "北证50",
"card_order": 6,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": false,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "bj899050",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/bj899050",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "印度孟买"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,90 @@
{
"name_comment": "显示的名",
"name": "巴菲特指",
"card_order_comment": "卡片排序序号,越大越靠后(当前最大",
"card_order": 88,
"card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
"card_type": "overseas",
"card_span_comment": "卡片横向占位列数",
"card_span": 2,
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "1h",
"symbol_comment": "监控的代码(技能内部定义)",
"symbol": "us_buffett_index",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/SP500",
"source_comment": "数据来源",
"source": "fred",
"alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)",
"alerts": {
"ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预",
"ratio_high_warning": 0.19,
"ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告",
"ratio_critical": 0.21,
"change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)",
"change_percent_down": -5,
"change_percent_up_comment": "单日涨幅告警阈值(比率变化百分比)",
"change_percent_up": 5,
"urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)",
"urgent_abs_change_percent": 8,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": false
}
],
"market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状",
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美股宏观"
}
}
@@ -0,0 +1,270 @@
const { fetchFredCurl } = require('../fred_curl')
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
const round = (v, d = 2) => {
const n = Number(v)
if (!Number.isFinite(n)) return NaN
const p = 10 ** d
return Math.round(n * p) / p
}
const parseFredCsv = raw => {
const text = typeof raw === 'string' ? raw : ''
const lines = text.split(/\r?\n/).filter(Boolean)
const out = []
for (let i = 1; i < lines.length; i++) {
const row = lines[i]
const idx = row.indexOf(',')
if (idx <= 0) continue
const date = row.slice(0, idx).trim()
const valueRaw = row.slice(idx + 1).trim()
if (!date || valueRaw === '.' || valueRaw === '') continue
const value = toNum(valueRaw)
if (!Number.isFinite(value)) continue
out.push({ date, value })
}
return out
}
const fetchFredSeries = async (id) => {
const csv = await fetchFredCurl(id)
return parseFredCsv(csv)
}
const tail = (arr, n) => (Array.isArray(arr) ? arr.slice(Math.max(0, arr.length - n)) : [])
const fmt = (v, d = 2) => Number.isFinite(v) ? v.toFixed(d) : '—'
const trendArrow = (delta, threshold = 0.001) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
if (Math.abs(d) < threshold) return '→'
return d > 0 ? '↑' : '↓'
}
const getValuationZone = ratio => {
if (!Number.isFinite(ratio)) return { zone: '未知', emoji: '⚪', color: '#8c8c8c', level: 'unknown' }
if (ratio < 0.10) return { zone: '显著低估', emoji: '🟢', color: '#15803d', level: 'low' }
if (ratio < 0.13) return { zone: '合理偏低', emoji: '🟢', color: '#16a34a', level: 'moderate_low' }
if (ratio < 0.16) return { zone: '合理估值', emoji: '🟡', color: '#ca8a04', level: 'fair' }
if (ratio < 0.19) return { zone: '偏高', emoji: '🟠', color: '#ea580c', level: 'elevated' }
if (ratio < 0.21) return { zone: '显著高估', emoji: '🔴', color: '#dc2626', level: 'high' }
return { zone: '极端高估', emoji: '🔴', color: '#b91c1c', level: 'extreme' }
}
const calcRatioSeries = (sp500Series, gdpSeries) => {
const out = []
if (!Array.isArray(sp500Series) || !sp500Series.length) return out
if (!Array.isArray(gdpSeries) || !gdpSeries.length) return out
const sp500Map = new Map()
for (const w of sp500Series) {
sp500Map.set(w.date, w.value)
}
const sp500Dates = Array.from(sp500Map.keys()).sort()
for (const g of gdpSeries) {
let bestDate = ''
for (const d of sp500Dates) {
if (d <= g.date) bestDate = d
else break
}
if (!bestDate) continue
const sp500Val = sp500Map.get(bestDate)
if (!Number.isFinite(sp500Val) || sp500Val <= 0) continue
const gdpVal = toNum(g.value)
if (!Number.isFinite(gdpVal) || gdpVal <= 0) continue
out.push({
date: g.date,
ratio: round(sp500Val / gdpVal, 4),
sp500: sp500Val,
gdp: gdpVal,
sp500Date: bestDate
})
}
return out
}
const getRatioPercentile = (ratioSeries, currentRatio) => {
if (!Array.isArray(ratioSeries) || ratioSeries.length < 4) return 50
const ratios = ratioSeries.map(r => r.ratio).filter(Number.isFinite).sort((a, b) => a - b)
let below = 0
for (const r of ratios) {
if (r <= currentRatio) below++
}
return round((below / ratios.length) * 100, 1)
}
const buildAlerts = (currentRatio, displayPrice, zoneLevel, config) => {
const alerts = []
const cfg = config && config.alerts ? config.alerts : {}
const ratioHighWarning = toNum(cfg.ratio_high_warning) || 0.19
const ratioCritical = toNum(cfg.ratio_critical) || 0.21
const displayWarning = round(ratioHighWarning * 100, 2)
const displayCritical = round(ratioCritical * 100, 2)
if (Number.isFinite(currentRatio) && currentRatio >= ratioCritical) {
alerts.push({
id: 'ratio_critical',
title: `${config.name}极端高估预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过严重阈值 ${displayCritical},市场估值处于极端高位`,
level: 'critical'
})
} else if (Number.isFinite(currentRatio) && currentRatio >= ratioHighWarning) {
alerts.push({
id: 'ratio_warning',
title: `${config.name}估值偏高预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过警告阈值 ${displayWarning},市场估值偏高`,
level: 'warning'
})
}
if (zoneLevel === 'extreme') {
const hasCritical = alerts.some(a => a.id === 'ratio_critical')
if (!hasCritical) {
alerts.push({
id: 'zone_extreme',
title: `${config.name}进入极端估值区间`,
message: `S&P500/GDP 估值比率 ${fmt(displayPrice, 2)} 进入极端高估区间,建议关注回调风险`,
level: 'critical'
})
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [sp500Series, gdpSeries] = await Promise.all([
fetchFredSeries('SP500'),
fetchFredSeries('GDP')
])
if (!sp500Series.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
if (!gdpSeries.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
const ratioSeries = calcRatioSeries(sp500Series, gdpSeries)
if (ratioSeries.length < 2) {
logJSON('skill.buffett.ratio.empty', { points: ratioSeries.length }, 'market_skills')
return null
}
const latestSp500 = tail(sp500Series, 1)[0]
const prevSp500 = sp500Series.length > 1 ? sp500Series[sp500Series.length - 2] : null
const latestGdp = tail(gdpSeries, 1)[0]
const currentRatio = round(latestSp500.value / latestGdp.value, 4)
const prevRatio = prevSp500
? round(prevSp500.value / latestGdp.value, 4)
: currentRatio
const dailyChange = round(currentRatio - prevRatio, 4)
const dailyChangePct = prevRatio ? round((dailyChange / prevRatio) * 100, 2) : 0
const latestRatioPoint = tail(ratioSeries, 1)[0]
const prevRatioPoint = ratioSeries.length > 1 ? ratioSeries[ratioSeries.length - 2] : null
const qoqChange = prevRatioPoint
? round(latestRatioPoint.ratio - prevRatioPoint.ratio, 4)
: NaN
const qoqArrow = trendArrow(qoqChange, 0.001)
const percentile = getRatioPercentile(ratioSeries, currentRatio)
const zone = getValuationZone(currentRatio)
const historyMin = Math.min(...ratioSeries.map(r => r.ratio))
const historyMax = Math.max(...ratioSeries.map(r => r.ratio))
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const displayPrice = round(currentRatio * 100, 2)
const displayPrev = round(prevRatio * 100, 2)
const displayChange = round(displayPrice - displayPrev, 2)
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${zone.emoji} ${zone.zone}(历史 ${fmt(percentile, 0)}% 分位)
</div>
<div style="font-size:14px;color:#1f1f1f;line-height:1.7;margin-top:4px">
估值比率:<span style="font-weight:700;color:${zone.color}">${fmt(displayPrice, 2)}</span>
<span style="font-size:13px;color:#595959;margin-left:4px">S&P500/GDP×100</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin-top:8px">
<div style="border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">📊 关键数据</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
S&P 500:${fmt(latestSp500.value, 2)}(${latestSp500.date})<br/>
GDP:${fmt(latestGdp.value, 2)} 十亿美元(${latestGdp.date})<br/>
日变动:${displayChange >= 0 ? '+' : ''}${fmt(displayChange, 2)}(${dailyChangePct >= 0 ? '+' : ''}${fmt(dailyChangePct, 2)}%)
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">📈 区间参考(×100)</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
最低:${fmt(historyMin * 100, 2)} | 最高:${fmt(historyMax * 100, 2)}<br/>
当前分位:${fmt(percentile, 0)}% | 较上季 ${qoqArrow}<br/>
低估<10|合理偏低13|合理16|偏高19|高估21
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
数据时间:${capturedAt}(FRED)| 基于S&P500与GDP比率×100,作为巴菲特指数代理
</div>
`
const explain = [
`巴菲特指数代理(S&P500/GDP×100):${fmt(displayPrice, 2)},处于${zone.zone}区间。`,
`历史${fmt(percentile, 0)}%分位,区间[${fmt(historyMin * 100, 2)}, ${fmt(historyMax * 100, 2)}]。`,
`S&P 500 指数 ${fmt(latestSp500.value, 2)},GDP ${fmt(latestGdp.value, 2)} 十亿美元。`,
`较上季度${Number.isFinite(qoqChange) ? (qoqChange > 0 ? '上升' : '下降') + fmt(Math.abs(qoqChange), 4) : '无变化'}。`,
`估值区间(×100):<10显著低估|10-13合理偏低|13-16合理|16-19偏高|19-21显著高估|>21极端高估。`,
`注:原巴菲特指数用Wilshire5000,FRED 2024年6月已下架该数据,现用S&P500/GDP×100代理。`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${zone.emoji} ${zone.zone}`,
price: displayPrice,
prevClose: displayPrev,
change: displayChange,
change_percent: dailyChangePct,
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
buffett_ratio: currentRatio,
buffett_display: displayPrice,
buffett_zone: zone.zone,
buffett_zone_emoji: zone.emoji,
buffett_percentile: percentile,
sp500_value: latestSp500.value,
sp500_date: latestSp500.date,
gdp_value: latestGdp.value,
gdp_date: latestGdp.date,
history_min: historyMin,
history_max: historyMax,
qoq_change: qoqChange
}]
const alerts = buildAlerts(currentRatio, displayPrice, zone.level, config)
return { data, alerts }
} catch (e) {
logJSON('skill.buffett.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "中韩半导体指",
"card_order": 19,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "中韩半导"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "创业板指",
"card_order": 5,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sz399006",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399006",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "创业"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,92 @@
{
"name_comment": "显示的名",
"name": "COMEX黄金",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hf_GC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/GC00Y.html",
"silver_symbol_comment": "白银代码 (用于计算金银",
"silver_symbol": "hf_SI",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"price_min_comment": "最低价格预",
"price_min": 4800,
"price_max_comment": "最高价格预",
"price_max": 5500,
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3,
"gold_silver_ratio_min_comment": "金银比最低预",
"gold_silver_ratio_min": 60,
"gold_silver_ratio_max_comment": "金银比最高预",
"gold_silver_ratio_max": 65
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "18:00",
"end": "17:00"
}
],
"display_label": "纽约"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,160 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (gold, silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Price Alerts
if (config.alerts.price_min && gold.price < config.alerts.price_min) {
addAlert('price_min', '黄金价格过低', `当前价格 ${gold.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && gold.price > config.alerts.price_max) {
addAlert('price_max', '黄金价格过高', `当前价格 ${gold.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && gold.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(gold.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '黄金跌幅预警', `当前跌幅 ${gold.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && gold.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(gold.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '黄金涨幅预警', `当前涨幅 ${gold.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
// 4. Gold/Silver Ratio
if (silver && silver.price > 0) {
const ratio = gold.price / silver.price
if (config.alerts.gold_silver_ratio_min && ratio < config.alerts.gold_silver_ratio_min) {
addAlert('ratio_min', '金银比过低', `当前金银比 ${ratio.toFixed(2)} 低于 ${config.alerts.gold_silver_ratio_min} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
if (config.alerts.gold_silver_ratio_max && ratio > config.alerts.gold_silver_ratio_max) {
addAlert('ratio_max', '金银比过高', `当前金银比 ${ratio.toFixed(2)} 高于 ${config.alerts.gold_silver_ratio_max} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
if (config.silver_symbol) symbols.push(config.silver_symbol)
const quotes = await fetchTencent(axios, symbols)
const gold = quotes.find(q => q.symbol === config.symbol)
const silver = quotes.find(q => q.symbol === config.silver_symbol)
if (!gold) {
logJSON('skill.gold.fetch.error', { message: 'Gold data not found' }, 'market_skills')
return null
}
// Prepare data for DB
// We only explicitly return Gold data to avoid duplication with Silver skill
// and to ensure the URL in config applies correctly to the main symbol
const data = quotes
.filter(q => q.symbol === config.symbol)
.map(q => ({
label: config.name,
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(gold, silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "COMEX白银",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hf_SI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"price_min_comment": "最低价格预",
"price_min": 75,
"price_max_comment": "最高价格预",
"price_max": 95,
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "18:00",
"end": "17:00"
}
],
"display_label": "纽约"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,143 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Price Alerts
if (config.alerts.price_min && silver.price < config.alerts.price_min) {
addAlert('price_min', '白银价格过低', `当前价格 ${silver.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && silver.price > config.alerts.price_max) {
addAlert('price_max', '白银价格过高', `当前价格 ${silver.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && silver.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(silver.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '白银跌幅预警', `当前跌幅 ${silver.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && silver.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(silver.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '白银涨幅预警', `当前涨幅 ${silver.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
const quotes = await fetchTencent(axios, symbols)
const silver = quotes.find(q => q.symbol === config.symbol)
if (!silver) {
logJSON('skill.silver.fetch.error', { message: 'Silver data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = quotes.map(q => ({
label: q.symbol === config.symbol ? config.name : (q.name || q.symbol),
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "沪深300",
"card_order": 3,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000300",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000300",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "沪深300"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,21 @@
{
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule": "1h",
"symbol": "sh000300_ma",
"source_symbol": "sh000300",
"name": "沪深300增强版",
"card_order": 7,
"card_type": "enhanced",
"source": "tencent",
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"alerts": {
"ma_cross": true,
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes": 120
}
}
+260
View File
@@ -0,0 +1,260 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
// Helper to calculate Simple Moving Average
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
// Helper to fetch K-line data from Tencent
const fetchKLine = async (axios, symbol) => {
// format: sh000300
// url: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh000300,day,,,300,qfq
// fetch 300 days to calculate MA250
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
// Response structure:
// { code: 0, msg: "", data: { [symbol]: { day: [ ["2023-01-01", "3800.00", "3850.00", ...], ... ], qfqday: [...] } } }
// If qfq (adjusted) exists, prefer it. Usually in `qfqday`.
const d = res.data.data[symbol]
if (!d) return null
// Use qfqday if available (adjusted prices), else day
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
// Extract closing prices (index 2 in Tencent array: [date, open, close, high, low, vol])
// Verify index:
// Usually: [date, open, close, high, low, volume]
// Example: ["2023-01-01", "10.00", "10.50", "10.60", "9.90", "1000"]
// Close is index 2.
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
console.error('Fetch KLine Error:', e.message)
return null
}
}
const checkAlerts = (ma120, ma250, prevMA120, prevMA250, dateStr) => {
const alerts = []
// Helper to add alert with slot-based cooldown
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// Logic:
// 1. Cross Down (Bearish): 120 跌破 250
// Condition: Prev(MA120 >= MA250) AND Curr(MA120 < MA250)
// Or simply: MA120 < MA250
// User wants alert when it happens.
// "如果120 跌破 250。发出警告...请减仓/卖出"
// To avoid noise, we check if the state *changed* from yesterday (or previous calculation).
// But K-line data includes "today" (if market open).
// So index N is today, N-1 is yesterday.
const isCrossDown = (prevMA120 >= prevMA250) && (ma120 < ma250)
const isCrossUp = (prevMA120 <= prevMA250) && (ma120 > ma250)
const vals = `(MA120: ${ma120.toFixed(2)}, MA250: ${ma250.toFixed(2)})`
// 1. Cross Down (Bearish): 120 跌破 250
if (isCrossDown) {
addAlert('cross_down', '120日线跌破250日线', `120 跌破 250。今天的两个数值分别是 ${vals},请减仓/卖出`)
}
// 2. Cross Up (Bullish): 120 上穿 250
if (isCrossUp) {
addAlert('cross_up', '120日线上穿250日线', `120 重新上穿 250。今天的两个数值分别是 ${vals},请重新买回`)
}
// 3. Convergence/Divergence Logic (Gap Analysis)
// Calculate percentage difference relative to MA250
const diffPercent = ((ma120 - ma250) / ma250) * 100
const absDiff = Math.abs(diffPercent)
// Thresholds
const CONVERGENCE_THRESHOLD = 1.0 // < 1% means very close (about to cross)
const DIVERGENCE_THRESHOLD = 3.0 // > 3% means trend confirmed
// (A) Approaching (Convergence) Alert - Warning Phase
// Trigger if gap is small (< 1%) AND wasn't already small yesterday (to avoid spamming every day in sticky market)
// OR simply remind if it enters this zone.
// Strategy: Alert if current gap < 1%.
// To avoid spam: relying on slot-based cooldown (once per AM/PM slot).
// Refinement: Only alert if it's "Approaching". i.e., Gap is shrinking?
// User said: "快要接近" -> Gap is small.
if (absDiff < CONVERGENCE_THRESHOLD) {
const direction = diffPercent > 0 ? '向下逼近' : '向上逼近'
addAlert('convergence', '均线高度粘合预警', `MA120 与 MA250 差距仅 ${absDiff.toFixed(2)}% (${direction}),即将面临方向选择,请密切关注! ${vals}`)
}
// (B) Diverging (Trend Confirmation) Alert - Safe Phase
// Trigger when gap widens beyond 3% AFTER being smaller.
// This implies we entered a trend.
// We check if *yesterday* was < 3% and *today* >= 3%.
const prevDiffPercent = ((prevMA120 - prevMA250) / prevMA250) * 100
const prevAbsDiff = Math.abs(prevDiffPercent)
if (absDiff >= DIVERGENCE_THRESHOLD && prevAbsDiff < DIVERGENCE_THRESHOLD) {
if (diffPercent > 0) {
// Bullish divergence
addAlert('divergence_bull', '多头趋势确认', `金叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),多头趋势稳固,建议坚定持有。 ${vals}`)
} else {
// Bearish divergence
addAlert('divergence_bear', '空头趋势确认', `死叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),下跌趋势加速,建议空仓观望。 ${vals}`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol // sh000300_ma (for DB/Uniqueness)
const sourceSymbol = config.source_symbol || 'sh000300' // Real symbol for API
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.csi300_ma.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
// Calculate MAs
// We need MA for today (latest) and yesterday (latest - 1)
const closes = klineData.map(d => d.close)
// Today
const ma120 = calculateMA(closes, 120)
const ma250 = calculateMA(closes, 250)
// Yesterday (remove last element)
const closesPrev = closes.slice(0, -1)
const prevMA120 = calculateMA(closesPrev, 120)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma120 || !ma250 || !prevMA120 || !prevMA250) {
logJSON('skill.csi300_ma.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
// Determine Status Text
let statusText = ''
if (ma120 > ma250) {
statusText = '继续定投、拿着'
} else if (ma120 < ma250) {
statusText = '减仓/卖出' // Or '空仓观望'
} else {
statusText = '均线粘合'
}
// Format for Card
// We want to show:
// Header: 沪深300增强版 · MA120: xxx | MA250: xxx (sh000300)
// Body: MA120 > MA250 :结论为“ 继续定投、拿着 ”
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const latestDate = klineData[klineData.length - 1].date
// Calculate Bias Ratio (乖离率)
// (Price - MA250) / MA250 * 100
const biasRatio = ((latestClose - ma250) / ma250) * 100
// Suggestion Logic
// If alerts exist, use the first one. Else, generic based on trend.
const alerts = checkAlerts(ma120, ma250, prevMA120, prevMA250, latestDate)
let suggestion = ''
if (alerts.length > 0) {
suggestion = alerts[0].message
} else {
// No active alert today, give general advice
if (ma120 > ma250) {
if (biasRatio > 10) suggestion = '乖离率过大 (>10%),注意短期回调风险'
else suggestion = '趋势向上,乖离率合理,可继续持有/定投'
} else {
if (biasRatio < -10) suggestion = '乖离率过大 (<-10%),可能存在超跌反弹'
else suggestion = '趋势向下,建议保持空仓或谨慎操作'
}
}
// Server-Driven UI (HTML Generation)
const isBull = ma120 > ma250
const conclusionColor = isBull ? '#f5222d' : '#52c41a'
const biasColor = biasRatio > 0 ? '#f5222d' : '#52c41a' // Red for positive bias (standard CN market color logic?) Or just neutral?
// Using standard CN colors: Red = Up/Bull, Green = Down/Bear
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${conclusionColor}">${statusText}</span> ”
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.5;">
乖离率:<span style="color:${biasColor}; font-weight:bold">${biasRatio.toFixed(2)}%</span> <br/>
提示:${suggestion}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: 'sh000300', // Show original symbol in UI
isMaCard: true,
render_type: 'html', // New SDUI flag
html: htmlPayload, // The payload
ma120: ma120.toFixed(2),
ma250: ma250.toFixed(2),
conclusion: statusText,
source: 'tencent',
name: statusText, // Fallback
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${statusText} (${latestDate})`
}]
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "道琼斯工业指",
"card_order": 13.5,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usDJI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usDJI",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "道琼"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+121
View File
@@ -0,0 +1,121 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) parts = body.split(',')
const name = parts[1]
const price = parseFloat(parts[3])
const prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
const slot = hour >= 0 && hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* FRED (Federal Reserve Economic Data) CSV fetcher via curl.exe
*
* Why curl.exe instead of axios:
* Node.js axios → FRED's Akamai CDN times out on SP500, DGS2, DGS10 series.
* curl.exe → same URL returns data in ~1s.
*
* Why date range params (cosd/coed):
* Full DGS2/DGS10 datasets (daily from 1962) are huge → Akamai times out.
* Limiting to recent years solves this. All calculations only need ≤3 years.
*
* Root cause: Akamai CDN differentiates by TLS fingerprint AND URL pattern.
*/
const { execFile } = require('child_process')
const FRED_BASE = 'https://fred.stlouisfed.org/graph/fredgraph.csv'
/**
* Fetch FRED CSV data using curl.exe with a date range limit
* @param {string} seriesId - FRED series ID (e.g. 'SP500', 'FEDFUNDS')
* @param {object} [opts]
* @param {number} [opts.timeout=20] - curl --max-time seconds
* @param {number} [opts.lookbackYears=3] - years to look back from today
* @param {string} [opts.coed] - custom observation end date YYYY-MM-DD (default today)
* @param {string} [opts.cosd] - custom observation start date YYYY-MM-DD (default coed - lookbackYears)
* @returns {Promise<string>} raw CSV text
*/
const fetchFredCurl = (seriesId, opts = {}) => {
const timeout = opts.timeout || 20
const lookbackYears = opts.lookbackYears || 3
const now = new Date()
const coed = opts.coed || now.toISOString().slice(0, 10)
const cosd = opts.cosd || (new Date(now.getTime() - lookbackYears * 365 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 10)
const url = `${FRED_BASE}?id=${encodeURIComponent(seriesId)}&cosd=${cosd}&coed=${coed}`
return new Promise((resolve, reject) => {
execFile('curl.exe', ['-s', '--max-time', String(timeout), url], {
encoding: 'utf8',
timeout: (timeout + 10) * 1000,
maxBuffer: 10 * 1024 * 1024,
windowsHide: true
}, (err, stdout, stderr) => {
if (err) {
const msg = stderr ? stderr.trim() : err.message
return reject(new Error(`curl failed for ${seriesId}: ${msg}`))
}
resolve(stdout)
})
})
}
module.exports = { fetchFredCurl }
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "德国DAX30",
"card_order": 10,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWG",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWG",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Europe/Berlin",
"sessions": [
{
"start": "09:00",
"end": "17:30"
}
],
"display_label": "德国DAX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,208 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
const parts = body.split('~')
if (parts.length < 3 && body.includes(',')) return null
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencentQuote = async (axios, symbol) => {
const url = `http://qt.gtimg.cn/q=${symbol}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return null
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
for (const line of lines) {
const r = parseTencentLine(line)
if (r) return { ...r, source: 'tencent' }
}
return null
} catch (e) {
return null
}
}
const fetchEastmoneyQuote = async (axios, url, symbol, nameFallback) => {
const secidMap = {
zsn225: '100.N225',
zsgdaxi: '100.GDAXI',
usnfty: '105.NFTY',
usflsa: '107.FLSA',
zsbvsp: '100.BVSP',
zh931790: '2.931790',
zssti: '100.STI',
zsks11: '100.KS11',
zskospi: '100.KS11',
zstwii: '100.TWII'
}
let secid = secidMap[String(symbol || '').toLowerCase()] || null
if (!secid && url) {
let html = ''
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
})
if (!res || res.status >= 400) return null
html = String(res.data || '')
} catch (e) {
return null
}
let m = html.match(/"secid"\s*:\s*"([0-9]+\.[^"]+)"/) || html.match(/secid\s*=\s*["']([0-9]+\.[^"']+)["']/)
if (!secid) {
const m2 = html.match(/quoteId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i) || html.match(/securityId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i)
if (m2) secid = m2[1]
}
if (!secid) {
const mMarket = html.match(/"market"\s*:\s*(\d+)/) || html.match(/market\s*=\s*(\d+)/)
const mCode = html.match(/"code"\s*:\s*"([^"]+)"/) || html.match(/code\s*=\s*["']([^"']+)["']/)
if (mMarket && mCode) secid = `${mMarket[1]}.${mCode[1]}`
}
}
if (!secid) return null
try {
const api = `https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=${encodeURIComponent(secid)}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57,f58&klt=101&fqt=0&end=20500101&lmt=2`
const resp = await axios.get(api, {
timeout: 10000,
validateStatus: () => true,
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
})
const data = resp && resp.data && resp.data.data
const klines = data && Array.isArray(data.klines) ? data.klines : []
if (!klines.length) return null
const parseLine = (line) => String(line || '').split(',')
const last = parseLine(klines[klines.length - 1])
const prev = klines.length > 1 ? parseLine(klines[klines.length - 2]) : null
const price = Number(last[2])
const prevClose = prev ? Number(prev[2]) : NaN
if (!Number.isFinite(price) || price <= 0) return null
const hasPrev = Number.isFinite(prevClose) && prevClose > 0
const change = hasPrev ? price - prevClose : 0
const change_percent = hasPrev ? (change / prevClose) * 100 : 0
const captured_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
return {
symbol,
name: data.name || nameFallback || symbol,
price,
prevClose: hasPrev ? prevClose : price,
change,
change_percent,
captured_at,
source: 'eastmoney'
}
} catch (e) {
return null
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const source = String(config.source || '').toLowerCase()
let item = null
if (source === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else if (source === 'tencent') {
item = await fetchTencentQuote(axios, config.symbol)
} else {
item = await fetchEastmoneyQuote(axios, config.url, config.symbol, config.name)
}
if (!item && source !== 'yahoo_probe' && String(config.card_type || '').toLowerCase() === 'overseas') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,43 @@
{
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule": "5m",
"name": "黄金白银净值跟",
"card_order": 16,
"card_type": "defensive",
"gold_fund_code": "518880",
"silver_fund_code": "161226",
"gold_symbol": "sh518880",
"silver_symbol": "sz161226",
"source": "tencent",
"email": {
"enabled": true,
"from": "yangdafe@gmail.com",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled": true,
"calendar_reminder_enabled": true,
"push_targets": [
{
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled": true,
"key": ""
},
{
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled": true
}
]
}
@@ -0,0 +1,244 @@
const iconv = require('iconv-lite')
const toBJDateTime = () => {
const d = new Date()
const utc = d.getTime() + d.getTimezoneOffset() * 60000
const bj = new Date(utc + 8 * 3600000)
const y = bj.getFullYear()
const m = String(bj.getMonth() + 1).padStart(2, '0')
const day = String(bj.getDate()).padStart(2, '0')
const hh = String(bj.getHours()).padStart(2, '0')
const mm = String(bj.getMinutes()).padStart(2, '0')
const ss = String(bj.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
const fetchFundGz = async (axios, code) => {
const url = `http://fundgz.1234567.com.cn/js/${code}.js?rt=${Date.now()}`
try {
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true, responseType: 'text' })
if (!res || res.status >= 400 || !res.data) return null
const m = String(res.data).match(/jsonpgz\((\{.*\})\)/)
if (!m) return null
const obj = JSON.parse(m[1])
const navStr = obj.gsz || obj.dwjz
const time = obj.gstime || obj.gztime || ''
const nav = parseFloat(navStr)
if (!Number.isFinite(nav)) return null
return { code, nav, time }
} catch { return null }
}
const fetchFundLastNav = async (axios, code) => {
const url = `https://api.fund.eastmoney.com/f10/lsjz?fundCode=${encodeURIComponent(code)}&pageIndex=1&pageSize=1`
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://fundf10.eastmoney.com/'
}
})
if (!res || res.status >= 400 || !res.data) return null
const payload = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const list = payload && payload.Data && Array.isArray(payload.Data.LSJZList) ? payload.Data.LSJZList : []
if (list.length === 0) return null
const row = list[0] || {}
const nav = parseFloat(row.DWJZ)
if (!Number.isFinite(nav)) return null
const time = String(row.FSRQ || '')
return { code, nav, time }
} catch { return null }
}
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) parts = body.split(',')
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (!Number.isFinite(price) || !Number.isFinite(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const toEastmoneySecid = (symbol) => {
const s = String(symbol || '').toLowerCase()
if (s.startsWith('sh') && s.length > 2) return `1.${s.slice(2)}`
if (s.startsWith('sz') && s.length > 2) return `0.${s.slice(2)}`
return null
}
const scaleEastmoneyPrice = v => {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return null
return n / 1000
}
const fetchEastmoneyQuote = async (axios, symbol) => {
const secid = toEastmoneySecid(symbol)
if (!secid) return null
const url = `https://push2.eastmoney.com/api/qt/stock/get?secid=${encodeURIComponent(secid)}&fields=f43,f57,f58,f60`
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://quote.eastmoney.com/'
}
})
if (!res || res.status >= 400 || !res.data || !res.data.data) return null
const d = res.data.data
const last = scaleEastmoneyPrice(d.f43)
const prev = scaleEastmoneyPrice(d.f60)
const price = Number.isFinite(last) ? last : prev
if (!Number.isFinite(price)) return null
return {
symbol,
name: String(d.f58 || symbol),
price,
prevClose: Number.isFinite(prev) ? prev : price,
change: Number.isFinite(prev) ? (price - prev) : 0,
change_percent: Number.isFinite(prev) && prev > 0 ? ((price - prev) / prev) * 100 : 0,
captured_at: toBJDateTime()
}
} catch { return null }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push(r)
}
return out
} catch { return [] }
}
const pct = (price, nav) => {
if (!Number.isFinite(price) || !Number.isFinite(nav) || nav === 0) return null
return ((price - nav) / nav) * 100
}
const tip = v => {
if (!Number.isFinite(v)) return '—'
if (Math.abs(v) <= 10) return '正常、安全、可交易区间'
if (v > 50) return '极端泡沫、必须警告'
if (v < -10) return '折价机会,可抄底信号'
return '—'
}
const buildPremiumAlerts = (label, premium) => {
if (!Number.isFinite(premium)) return []
const fmt = premium.toFixed(2)
if (premium > 50) {
return [{
id: `${label}_premium_bubble`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (> 50%),极端泡沫、必须警告`,
level: 'critical'
}]
}
if (premium < -10) {
return [{
id: `${label}_premium_discount`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (< -10%),折价机会,可抄底信号`,
level: 'warning'
}]
}
return []
}
module.exports = {
run: async (context, config) => {
const { axios } = context
const goldFund = String(config.gold_fund_code || '518880')
const silverFund = String(config.silver_fund_code || '161226')
const goldSymbol = String(config.gold_symbol || 'sh518880')
const silverSymbol = String(config.silver_symbol || 'sz161226')
const [goldGzRaw, silverGzRaw] = await Promise.all([
fetchFundGz(axios, goldFund),
fetchFundGz(axios, silverFund)
])
const [goldNavFallback, silverNavFallback] = await Promise.all([
goldGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, goldFund),
silverGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, silverFund)
])
const goldGz = goldGzRaw || goldNavFallback
const silverGz = silverGzRaw || silverNavFallback
const quotes = await fetchTencent(axios, [goldSymbol, silverSymbol])
let goldQ = quotes.find(q => q.symbol === goldSymbol)
let silverQ = quotes.find(q => q.symbol === silverSymbol)
const [goldQuoteFallback, silverQuoteFallback] = await Promise.all([
(goldQ && Number.isFinite(goldQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, goldSymbol),
(silverQ && Number.isFinite(silverQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, silverSymbol)
])
if ((!goldQ || !Number.isFinite(goldQ.price)) && goldQuoteFallback) goldQ = goldQuoteFallback
if ((!silverQ || !Number.isFinite(silverQ.price)) && silverQuoteFallback) silverQ = silverQuoteFallback
const goldNav = goldGz ? goldGz.nav : null
const silverNav = silverGz ? silverGz.nav : null
const goldPrice = goldQ ? goldQ.price : null
const silverPrice = silverQ ? silverQ.price : null
const goldPremium = pct(goldPrice, goldNav)
const silverPremium = pct(silverPrice, silverNav)
const captured_at = toBJDateTime()
const data = [{
label: config.name || '黄金白银净值跟踪',
symbol: 'skill_gold_silver_nav',
source: 'fundgz+tencent+eastmoney_fallback',
name: 'ETF/LOF',
displaySymbol: `${goldFund}/${silverFund}`,
price: Number.isFinite(goldPrice) ? goldPrice : null,
prevClose: null,
change: null,
change_percent: null,
render_type: 'gold_silver_nav',
gold_nav: goldNav,
gold_price: goldPrice,
gold_premium: goldPremium,
gold_tip: tip(goldPremium),
silver_nav: silverNav,
silver_price: silverPrice,
silver_premium: silverPremium,
silver_tip: tip(silverPremium),
url: `https://fund.eastmoney.com/${goldFund}.html`,
captured_at,
market_status: config.market_status || null
}]
const alerts = [
...buildPremiumAlerts('黄金', goldPremium),
...buildPremiumAlerts('白银', silverPremium)
]
return { data, alerts }
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "恒生指数",
"card_order": 8,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hkHSI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/hkHSI",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Hong_Kong",
"sessions": [
{
"start": "09:30",
"end": "12:00"
},
{
"start": "13:00",
"end": "16:00"
}
],
"display_label": "恒生"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "印度NFTY(",
"card_order": 11,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usNFTY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usNFTY",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "印度"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "韩国综合指数",
"card_order": 86,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWY",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "韩国"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,84 @@
{
"name_comment": "显示的名",
"name": "流动性状态机",
"card_order": 6,
"card_type": "overseas",
"card_span_comment": "卡片横向占位列数",
"card_span": 2,
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "6h",
"symbol_comment": "监控的代",
"symbol": "us_liquidity_state",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/FEDFUNDS",
"source_comment": "数据来源 (",
"source": "fred",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -20,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 40,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 20
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美国宏观"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,442 @@
const { fetchFredCurl } = require('../fred_curl')
const FRED_SERIES = {
ffr: 'FEDFUNDS',
us2y: 'DGS2',
us10y: 'DGS10',
cpi_index: 'CPIAUCSL',
unemployment: 'UNRATE'
}
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
const round = (v, d = 2) => {
const n = Number(v)
if (!Number.isFinite(n)) return NaN
const p = 10 ** d
return Math.round(n * p) / p
}
const clamp = (v, min, max) => Math.min(max, Math.max(min, v))
const parseFredCsv = raw => {
const text = typeof raw === 'string' ? raw : ''
const lines = text.split(/\r?\n/).filter(Boolean)
const out = []
for (let i = 1; i < lines.length; i++) {
const row = lines[i]
const idx = row.indexOf(',')
if (idx <= 0) continue
const date = row.slice(0, idx).trim()
const valueRaw = row.slice(idx + 1).trim()
if (!date || valueRaw === '.' || valueRaw === '') continue
const value = toNum(valueRaw)
if (!Number.isFinite(value)) continue
out.push({ date, value })
}
return out
}
const fetchFredSeries = async (id) => {
const csv = await fetchFredCurl(id)
return parseFredCsv(csv)
}
const tail = (arr, n) => (Array.isArray(arr) ? arr.slice(Math.max(0, arr.length - n)) : [])
const avg = arr => {
if (!Array.isArray(arr) || arr.length === 0) return NaN
const nums = arr.map(x => toNum(x)).filter(Number.isFinite)
if (nums.length === 0) return NaN
return nums.reduce((a, b) => a + b, 0) / nums.length
}
const sma = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const values = tail(series.map(x => x.value), Math.max(1, n))
return avg(values)
}
const valueNBack = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const idx = Math.max(0, series.length - 1 - Math.max(0, n))
return toNum(series[idx] && series[idx].value)
}
const cpiYoYSeries = cpiSeries => {
const out = []
if (!Array.isArray(cpiSeries) || cpiSeries.length < 13) return out
for (let i = 12; i < cpiSeries.length; i++) {
const curr = toNum(cpiSeries[i].value)
const prev = toNum(cpiSeries[i - 12].value)
if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev <= 0) continue
out.push({
date: cpiSeries[i].date,
value: ((curr / prev) - 1) * 100
})
}
return out
}
const scoreFromDelta = (delta, easingThreshold, tighteningThreshold) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return 50
if (d <= easingThreshold) return 100
if (d >= tighteningThreshold) return 0
const mid = (easingThreshold + tighteningThreshold) / 2
const half = (tighteningThreshold - easingThreshold) / 2 || 1
const scaled = 50 - ((d - mid) / half) * 50
return clamp(scaled, 0, 100)
}
const scoreRateDirection = ffrSeries => {
const latest = valueNBack(ffrSeries, 0)
const prev3 = valueNBack(ffrSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, -0.1, 0.1) }
}
const scoreYieldExpectation = us2ySeries => {
const latest = valueNBack(us2ySeries, 0)
const prev30 = valueNBack(us2ySeries, 30)
const delta = latest - prev30
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreCpiTrend = cpiYoY => {
const recent = tail(cpiYoY, 4)
if (recent.length < 4) return { delta: NaN, score: 50 }
const m3 = toNum(recent[0].value)
const m2 = toNum(recent[1].value)
const m1 = toNum(recent[2].value)
const m0 = toNum(recent[3].value)
const delta = m0 - m3
const isThreeMonthDown = m0 < m1 && m1 < m2
const isThreeMonthUp = m0 > m1 && m1 > m2
if (isThreeMonthDown) return { delta, score: 100 }
if (isThreeMonthUp) return { delta, score: 0 }
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreUnemployment = unrateSeries => {
const latest = valueNBack(unrateSeries, 0)
const prev3 = valueNBack(unrateSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, 0.3, -0.2) }
}
const buildState = score => {
if (score >= 85) return { state: '宽松', emoji: '🟢', impact: '流动性充分改善,利好纳指与标普,纳指弹性更高' }
if (score >= 70) return { state: '宽松初期', emoji: '🟢+', impact: '宽松预期先行,通常是纳指表现最优的阶段' }
if (score >= 45) return { state: '中性', emoji: '🟡', impact: '标普与纳指以震荡为主,等待增量信号' }
if (score >= 30) return { state: '紧缩中', emoji: '🟠', impact: '估值受压但未到极端,纳指波动通常大于标普' }
return { state: '紧缩', emoji: '🔴', impact: '高利率压制风险资产,纳指承压最明显' }
}
const fmt = (v, d = 2, suffix = '') => Number.isFinite(v) ? `${round(v, d).toFixed(d)}${suffix}` : '—'
const trendArrow = (delta, weak, strong) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
const ad = Math.abs(d)
if (ad < weak) return '→'
if (d > 0) return ad >= strong ? '↑↑↑' : '↑'
return ad >= strong ? '↓↓↓' : '↓'
}
const dominantFactorFromDeltas = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => {
const candidates = [
{ key: 'FFR利率方向', short: 'FFR利率', delta: rateDelta, unitThreshold: 0.1, easingWhenNegative: true },
{ key: '2Y利率预期', short: '2Y预期', delta: y2Delta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: 'CPI通胀趋势', short: 'CPI趋势', delta: cpiDelta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: '失业率变化', short: '失业率', delta: unrateDelta, unitThreshold: 0.3, easingWhenNegative: false }
].map(x => ({
...x,
strength: Number.isFinite(x.delta) ? Math.abs(x.delta) / x.unitThreshold : -1
}))
candidates.sort((a, b) => b.strength - a.strength)
const top = candidates[0]
const delta = toNum(top && top.delta)
const easing = Number.isFinite(delta)
? (top.easingWhenNegative ? delta < 0 : delta > 0)
: false
return {
dominant_factor: top ? top.short : '综合因素',
dominant_factor_full: top ? top.key : '综合因素',
dominant_bias: easing ? '宽松预期' : '紧缩预期'
}
}
const buildMarketPhase = ({ score, ffrLevel, rateDelta, y2Delta }) => {
if (rateDelta > 0.1) return '紧缩期(加息)'
if (score >= 85 && rateDelta < 0 && y2Delta < -0.2) return '宽松释放期(大牛)'
if ((y2Delta <= -0.15 && rateDelta <= 0.05) || (score >= 65 && score < 85)) return '降息预期交易期(Pre-Easing)'
if (ffrLevel >= 4 || score < 65) return '高位压制期'
return '高位压制期'
}
const buildIndexView = ({ score, y2Delta, cpiDelta, unrateDelta }) => {
const nasdaqBias = (score >= 70 || y2Delta <= -0.15) ? '当前偏正面' : (score >= 45 ? '中性偏震荡' : '当前偏负面')
const sp500Bias = (score >= 85 && cpiDelta < 0 && unrateDelta >= 0)
? '偏正面'
: (score < 45 ? '偏谨慎' : '仍有分歧')
return {
nasdaq: `纳指驱动:流动性 + 利率预期(${nasdaqBias})`,
sp500: `标普驱动:盈利 + 宏观(${sp500Bias})`,
nasdaq_bias: nasdaqBias,
sp500_bias: sp500Bias
}
}
const buildSignalStrength = score => {
if (score >= 85) return '★★★★★'
if (score >= 70) return '★★★☆☆'
if (score >= 45) return '★★☆☆☆'
if (score >= 30) return '★☆☆☆☆'
return '☆☆☆☆☆'
}
const buildCoreConclusion = marketPhase => {
if (marketPhase === '降息预期交易期(Pre-Easing)') {
return '市场进入“降息预期交易期”,流动性方向转松,当前更利好纳指(科技股),而非全面牛市。'
}
if (marketPhase === '宽松释放期(大牛)') {
return '市场进入“宽松释放期”,流动性进入释放阶段,纳指与标普更容易共振上行。'
}
if (marketPhase === '紧缩期(加息)') {
return '市场处于“紧缩期”,流动性方向偏紧,纳指与标普整体承压。'
}
return '市场处于“高位压制期”,风险偏好恢复有限,结构性行情强于全面行情。'
}
const buildTrendNarrative = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => ({
ffr: rateDelta < 0 ? '政策开始转向' : (rateDelta > 0 ? '政策偏紧延续' : '政策维持稳定'),
us2y: y2Delta < 0 ? '市场正在押注降息' : (y2Delta > 0 ? '市场预期反向' : '市场预期观望'),
cpi: cpiDelta < 0 ? '支持降息' : (cpiDelta > 0 ? '约束降息' : '通胀信号中性'),
unemployment: unrateDelta > 0 ? '经济走弱' : (unrateDelta < 0 ? '经济尚稳' : '就业信号中性')
})
const buildRiskHints = ({ rateDelta, y2Delta, cpiDelta, marketPhase }) => {
const divergence = (rateDelta < 0 && y2Delta > 0.1) || (rateDelta > 0 && y2Delta < -0.1)
return [
divergence ? '2Y与FFR出现分歧(预期 vs 现实)' : '2Y与FFR短期一致,需警惕后续再分歧',
cpiDelta > 0 ? 'CPI出现反弹迹象,降息预期存在回摆风险' : '若CPI反弹 → 降息预期失效',
marketPhase === '宽松释放期(大牛)' ? '宽松释放已启动,仍需跟踪盈利兑现节奏' : '当前仍未进入真实宽松(资金未释放)'
]
}
const buildDominantDisplay = ({ dominantFactorFull, arrows, trendNarrative }) => {
if (dominantFactorFull === 'FFR利率方向') return { arrow: arrows.ffr, period: '3个月', text: trendNarrative.ffr }
if (dominantFactorFull === 'CPI通胀趋势') return { arrow: arrows.cpi, period: '3个月', text: trendNarrative.cpi }
if (dominantFactorFull === '失业率变化') return { arrow: arrows.unemployment, period: '3个月', text: trendNarrative.unemployment }
return { arrow: arrows.us2y, period: '30日', text: trendNarrative.us2y }
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [ffr, us2y, us10y, cpiIndex, unemployment] = await Promise.all([
fetchFredSeries(FRED_SERIES.ffr),
fetchFredSeries(FRED_SERIES.us2y),
fetchFredSeries(FRED_SERIES.us10y),
fetchFredSeries(FRED_SERIES.cpi_index),
fetchFredSeries(FRED_SERIES.unemployment)
])
if (!ffr.length || !us2y.length || !us10y.length || !cpiIndex.length || !unemployment.length) {
logJSON('skill.liquidity.fetch.empty', {
ffr: ffr.length,
us2y: us2y.length,
us10y: us10y.length,
cpiIndex: cpiIndex.length,
unemployment: unemployment.length
}, 'market_skills')
return null
}
const cpiYoY = cpiYoYSeries(cpiIndex)
const ratePart = scoreRateDirection(ffr)
const yieldPart = scoreYieldExpectation(us2y)
const cpiPart = scoreCpiTrend(cpiYoY)
const unratePart = scoreUnemployment(unemployment)
const totalScore = clamp(
ratePart.score * 0.4 +
yieldPart.score * 0.3 +
cpiPart.score * 0.2 +
unratePart.score * 0.1,
0,
100
)
const totalScoreRounded = round(totalScore, 2)
const state = buildState(totalScoreRounded)
const latestFfr = tail(ffr, 1)[0]
const latestUs2y = tail(us2y, 1)[0]
const latestUs10y = tail(us10y, 1)[0]
const latestCpiYoY = tail(cpiYoY, 1)[0]
const latestUnrate = tail(unemployment, 1)[0]
const spread = toNum(latestUs10y.value) - toNum(latestUs2y.value)
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const ffrTrend = latestFfr.value - valueNBack(ffr, 3)
const us2yTrend = latestUs2y.value - valueNBack(us2y, 30)
const cpiTrend = (latestCpiYoY ? latestCpiYoY.value : NaN) - valueNBack(cpiYoY, 3)
const unrateTrend = latestUnrate.value - valueNBack(unemployment, 3)
const ffrArrow = trendArrow(ffrTrend, 0.03, 0.1)
const us2yArrow = trendArrow(us2yTrend, 0.05, 0.3)
const cpiArrow = trendArrow(cpiTrend, 0.05, 0.3)
const unrateArrow = trendArrow(unrateTrend, 0.05, 0.2)
const dominant = dominantFactorFromDeltas({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const marketPhase = buildMarketPhase({
score: totalScoreRounded,
ffrLevel: latestFfr.value,
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta
})
const indexView = buildIndexView({
score: totalScoreRounded,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const signalStrength = buildSignalStrength(totalScoreRounded)
const coreConclusion = buildCoreConclusion(marketPhase)
const trendNarrative = buildTrendNarrative({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const riskHints = buildRiskHints({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
marketPhase
})
const structureSummary = '科技优先上涨,而非全面上涨'
const dominantDisplay = buildDominantDisplay({
dominantFactorFull: dominant.dominant_factor_full,
arrows: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
trendNarrative
})
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${state.emoji} ${state.state}(${marketPhase.includes('Pre-Easing') ? 'Pre-Easing' : state.state})<br/>
流动性评分:${fmt(totalScoreRounded, 0)} / 100 | 信号强度:${signalStrength}
</div>
<div style="font-size:13px;color:#1f1f1f;line-height:1.7;margin-top:8px">
👉 核心结论:${coreConclusion}
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;margin-top:10px">
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">🎯 主导因子</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
${dominant.dominant_factor_full} ${dominantDisplay.arrow}(${dominantDisplay.period})<br/>
→ ${dominantDisplay.text}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">🧭 市场结构判断</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
纳指:✔ 长久期资产受益 | ✔ 估值扩张预期增强<br/>
标普:△ 盈利支撑仍在 | △ 宏观存在分歧<br/>
👉 当前结构:${structureSummary}
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">⚠️ 风险提示</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
- ${riskHints[0]}<br/>
- ${riskHints[1]}<br/>
- ${riskHints[2]}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">📊 关键趋势</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
FFR:${fmt(latestFfr.value, 2, '%')} ${ffrArrow}(${trendNarrative.ffr})<br/>
2Y:${fmt(latestUs2y.value, 2, '%')} ${us2yArrow}(${trendNarrative.us2y})<br/>
CPI:${fmt(latestCpiYoY && latestCpiYoY.value, 2, '%')} ${cpiArrow}(${trendNarrative.cpi})<br/>
失业率:${fmt(latestUnrate.value, 2, '%')} ${unrateArrow}(${trendNarrative.unemployment})
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
分项评分:利率方向 ${fmt(ratePart.score, 0)} | 2Y预期 ${fmt(yieldPart.score, 0)} | CPI趋势 ${fmt(cpiPart.score, 0)} | 失业率 ${fmt(unratePart.score, 0)}<br/>
数据时间:${capturedAt}(FRED)
</div>
`
const explain = [
`当前阶段:${marketPhase}。`,
`当前市场由【${dominant.dominant_factor_full}】主导,偏向${dominant.dominant_bias}。`,
`${indexView.nasdaq} ${indexView.sp500}。`,
`核心结论:${coreConclusion}`,
`当前结构:${structureSummary}。`,
`纳指影响:${state.state === '宽松' || state.state === '宽松初期' ? '长久期资产受益,估值扩张概率更高。' : state.state === '中性' ? '以交易驱动为主,业绩与预期差决定方向。' : '高利率压制估值,回撤风险先体现在纳指。'}`,
`标普影响:${state.state === '宽松' || state.state === '宽松初期' ? '风险偏好抬升,周期与成长轮动走强。' : state.state === '中性' ? '盈利韧性与宏观数据反复博弈。' : '防御板块相对占优,指数上行空间受限。'}`,
`风险提示:${riskHints.join(';')}。`,
`分项得分:利率方向 ${fmt(ratePart.score, 1)}|2Y预期 ${fmt(yieldPart.score, 1)}|CPI趋势 ${fmt(cpiPart.score, 1)}|失业率 ${fmt(unratePart.score, 1)}`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${state.emoji} ${state.state}`,
price: totalScoreRounded,
prevClose: 50,
change: round(totalScoreRounded - 50, 2),
change_percent: round(((totalScoreRounded - 50) / 50) * 100, 2),
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
liquidity_state: state.state,
liquidity_score: totalScoreRounded,
market_phase: marketPhase,
signal_strength: signalStrength,
core_conclusion: coreConclusion,
market_structure_summary: structureSummary,
dominant_factor: dominant.dominant_factor,
dominant_factor_full: dominant.dominant_factor_full,
dominant_bias: dominant.dominant_bias,
index_view: {
nasdaq: indexView.nasdaq,
sp500: indexView.sp500,
nasdaq_bias: indexView.nasdaq_bias,
sp500_bias: indexView.sp500_bias
},
risk_hints: riskHints,
date: capturedAt.slice(0, 10),
ffr: round(latestFfr.value, 4),
us2y: round(latestUs2y.value, 4),
us10y: round(latestUs10y.value, 4),
cpi: round(latestCpiYoY && latestCpiYoY.value, 4),
unemployment: round(latestUnrate.value, 4),
trend_arrow: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
series_date: {
ffr: latestFfr.date,
us2y: latestUs2y.date,
us10y: latestUs10y.date,
cpi: latestCpiYoY ? latestCpiYoY.date : null,
unemployment: latestUnrate.date
}
}]
return { data, alerts: [] }
} catch (e) {
logJSON('skill.liquidity.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}
+879
View File
@@ -0,0 +1,879 @@
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const nodemailer = require('nodemailer')
const { logJSON } = require('../logger')
const { db } = require('../db')
const securityCalendar = require('../security_calendar')
const SKILLS_DIR = __dirname
const HISTORY_FILE = path.join(SKILLS_DIR, 'alert_history.json')
// 从 Toolbox_local_creds.env.local 加载本地凭证,用于解析 {{VAR}} 占位符
const localCreds = (() => {
try {
const userProfile = process.env.USERPROFILE || process.env.HOME || ''
const p = path.join(userProfile, 'Toolbox_local_creds.env.local')
if (!fs.existsSync(p)) return {}
const content = fs.readFileSync(p, 'utf8')
const creds = {}
content.split(/\r?\n/).forEach(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const idx = trimmed.indexOf('=')
if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
})
return creds
} catch { return {} }
})()
// 将配置中的 {{VAR}} 占位符替换为实际值
const resolvePlaceholders = (val) => {
if (typeof val !== 'string') return val
return val.replace(/\{\{(.+?)\}\}/g, (_, name) => localCreds[name] || process.env[name] || `{{${name}}}`)
}
const skills = new Map()
const lastDispatchSignature = new Map()
const retryQueue = []
let retryTimer = null
const TITLE_INLINE_HAN_LIMIT = 50
const TITLE_POLICY_MARKER = 'FULL_TITLE_50CN_V1'
const chinaTradingCalendarCache = new Map()
const getAlertSlot = db.prepare(
`SELECT slot_key FROM alert_history WHERE skill = ? AND alert_id = ?`
)
const upsertAlertSlot = db.prepare(
`INSERT INTO alert_history (skill, alert_id, slot_key, updated_at)
VALUES (@skill, @alert_id, @slot_key, datetime('now'))
ON CONFLICT(skill, alert_id) DO UPDATE SET
slot_key = excluded.slot_key,
updated_at = datetime('now')`
)
const getMarketState = db.prepare(`SELECT value FROM market_state WHERE key=?`)
const upsertMarketState = db.prepare(
`INSERT INTO market_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`
)
const insertEmailLog = db.prepare(
`INSERT OR IGNORE INTO alert_email_log (skill, alert_id, subject, status, error_message, created_at)
VALUES (@skill, @alert_id, @subject, @status, @error_message, datetime('now'))`
)
const updateEmailLogStatus = db.prepare(
`UPDATE alert_email_log SET status = @status, error_message = @error_message
WHERE id = @id`
)
const migrateHistoryFromFile = () => {
if (!fs.existsSync(HISTORY_FILE)) return
let data = null
try {
data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'))
} catch (e) {
logJSON('skills.history.error', { error: String(e.message) }, 'market_skills')
return
}
const entries = Object.entries(data || {})
if (entries.length === 0) return
const tx = db.transaction(list => {
for (const [key, slot] of list) {
if (!slot) continue
const idx = key.indexOf(':')
if (idx <= 0) continue
const skill = key.slice(0, idx)
const alert_id = key.slice(idx + 1)
if (!skill || !alert_id) continue
upsertAlertSlot.run({ skill, alert_id, slot_key: String(slot) })
}
})
tx(entries)
}
migrateHistoryFromFile()
const DEFAULT_POLICY = {
urgentAbsChangePercent: 5,
urgentCooldownMinutes: 120,
nonChinaDailyCap: 3,
nonChinaPerSkillDailyCap: 1,
chinaAmReminderTime: '11:25',
chinaPmReminderTime: '14:55',
dailySummaryTime: '21:30'
}
const readStateJson = (key, fallback) => {
try {
const row = getMarketState.get(key)
if (!row || typeof row.value !== 'string' || !row.value) return fallback
return JSON.parse(row.value)
} catch {
return fallback
}
}
const writeStateJson = (key, value) => {
try {
upsertMarketState.run({ key, value: JSON.stringify(value || {}) })
} catch (e) {
logJSON('skills.state.write.error', { key, error: String(e.message || e) }, 'market_skills')
}
}
const toBJDate = (d = new Date()) => {
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
return new Date(utc + (3600000 * 8))
}
const pad2 = n => String(n).padStart(2, '0')
const bjParts = (d = new Date()) => {
const bj = toBJDate(d)
const year = bj.getFullYear()
const month = pad2(bj.getMonth() + 1)
const day = pad2(bj.getDate())
const hour = bj.getHours()
const minute = bj.getMinutes()
const second = bj.getSeconds()
return {
year,
month,
day,
hour,
minute,
second,
dateStr: `${year}-${month}-${day}`,
timeStr: `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
}
}
const bjNowDateTime = () => {
const p = bjParts()
return `${p.dateStr} ${p.timeStr}`
}
const parseHHMM = hhmm => {
const s = String(hhmm || '').trim()
const m = s.match(/^(\d{1,2}):(\d{2})$/)
if (!m) return { hour: 0, minute: 0 }
return { hour: Math.max(0, Math.min(23, parseInt(m[1], 10))), minute: Math.max(0, Math.min(59, parseInt(m[2], 10))) }
}
const localTs = (dateStr, hhmm) => `${dateStr} ${hhmm}`
const nextDateStr = (dateStr) => {
const d = new Date(`${dateStr}T00:00:00.000Z`)
d.setUTCDate(d.getUTCDate() + 1)
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
}
const isChinaTradingDate = async dateStr => {
const safeDate = String(dateStr || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(safeDate)) return true
const monthKey = safeDate.slice(0, 7)
if (!chinaTradingCalendarCache.has(monthKey)) {
try {
const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false })
chinaTradingCalendarCache.set(monthKey, payload)
} catch {
chinaTradingCalendarCache.set(monthKey, null)
}
}
const payload = chinaTradingCalendarCache.get(monthKey)
const days = payload && Array.isArray(payload.days) ? payload.days : []
const day = days.find(item => String((item && item.date) || '') === safeDate)
if (!day) return true
return day.is_trading_day !== false
}
const safeSkillId = (id = '') => String(id).replace(/[^a-zA-Z0-9_-]/g, '_')
const getPolicy = config => {
const p = (config && config.alert_policy) || {}
const a = (config && config.alerts) || {}
return {
urgentAbsChangePercent: Number.isFinite(Number(a.urgent_abs_change_percent)) ? Math.abs(Number(a.urgent_abs_change_percent)) : (Number.isFinite(Number(p.urgent_abs_change_percent)) ? Math.abs(Number(p.urgent_abs_change_percent)) : DEFAULT_POLICY.urgentAbsChangePercent),
urgentCooldownMinutes: Number.isFinite(Number(a.urgent_cooldown_minutes)) ? Math.max(1, Number(a.urgent_cooldown_minutes)) : (Number.isFinite(Number(p.urgent_cooldown_minutes)) ? Math.max(1, Number(p.urgent_cooldown_minutes)) : DEFAULT_POLICY.urgentCooldownMinutes),
nonChinaDailyCap: Number.isFinite(Number(p.non_china_daily_cap)) ? Math.max(1, Number(p.non_china_daily_cap)) : DEFAULT_POLICY.nonChinaDailyCap,
nonChinaPerSkillDailyCap: Number.isFinite(Number(p.non_china_per_skill_daily_cap)) ? Math.max(1, Number(p.non_china_per_skill_daily_cap)) : DEFAULT_POLICY.nonChinaPerSkillDailyCap,
chinaAmReminderTime: typeof p.china_am_reminder_time === 'string' ? p.china_am_reminder_time : DEFAULT_POLICY.chinaAmReminderTime,
chinaPmReminderTime: typeof p.china_pm_reminder_time === 'string' ? p.china_pm_reminder_time : DEFAULT_POLICY.chinaPmReminderTime,
dailySummaryTime: typeof p.daily_summary_time === 'string' ? p.daily_summary_time : DEFAULT_POLICY.dailySummaryTime
}
}
const isChinaSecuritySkill = config => {
const timezone = String(((config || {}).market_status || {}).timezone || '')
const cardType = String((config || {}).card_type || '')
const name = String((config || {}).name || '')
if (timezone !== 'Asia/Shanghai') return false
if (cardType === 'domestic' || cardType === 'enhanced') return true
return /上证|深证|创业板|科创|沪深|北证|A股/.test(name)
}
const getDayState = dateStr => {
const key = `skills.day_state.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { nonChinaTotal: 0, nonChinaSkills: {}, dailySummarySent: false, nonChinaFirstSent: false }
}
const setDayState = (dateStr, state) => {
writeStateJson(`skills.day_state.${dateStr}`, state)
}
const getDeferredList = dateStr => readStateJson(`skills.deferred.${dateStr}`, [])
const setDeferredList = (dateStr, list) => writeStateJson(`skills.deferred.${dateStr}`, Array.isArray(list) ? list : [])
const getLatestSkillDataState = skillId => {
const rows = readStateJson(`skills.latest.${safeSkillId(skillId)}`, [])
return Array.isArray(rows) ? rows : []
}
const setLatestSkillDataState = (skillId, rows) => {
writeStateJson(`skills.latest.${safeSkillId(skillId)}`, Array.isArray(rows) ? rows : [])
}
const appendDeferred = (dateStr, rows) => {
if (!Array.isArray(rows) || rows.length === 0) return
const old = getDeferredList(dateStr)
const merged = old.concat(rows).slice(-500)
setDeferredList(dateStr, merged)
}
const getSummaryPool = dateStr => {
const key = `skills.summary_pool.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { regular: [], urgent: [] }
}
const setSummaryPool = (dateStr, pool) => {
writeStateJson(`skills.summary_pool.${dateStr}`, pool || { regular: [], urgent: [] })
}
const addSummaryRows = (dateStr, rows, urgent = false) => {
if (!Array.isArray(rows) || rows.length === 0) return
const pool = getSummaryPool(dateStr)
const keySet = new Set()
const normalize = r => {
const skill = String(r.skill || '')
const alertId = String(r.alertId || '')
const title = String(r.title || '')
const message = String(r.message || '')
const ts = String(r.ts || '')
return { skill, alertId, title, message, ts }
}
const load = arr => {
for (const x of (Array.isArray(arr) ? arr : [])) {
const n = normalize(x)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
}
}
load(pool.regular)
load(pool.urgent)
const target = urgent ? (Array.isArray(pool.urgent) ? pool.urgent : []) : (Array.isArray(pool.regular) ? pool.regular : [])
for (const row of rows) {
const n = normalize(row)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
target.push(n)
}
if (urgent) pool.urgent = target
else pool.regular = target
setSummaryPool(dateStr, pool)
}
const dedupeByAlert = list => {
const map = new Map()
for (const it of list || []) {
const k = `${it.skill || ''}::${it.alertId || it.title || ''}`
if (!map.has(k)) map.set(k, it)
}
return Array.from(map.values())
}
const resolveUrl = (url) => {
const port = process.env.PORT || 8976
return String(url || '').replace(/{{PORT}}/g, port)
}
const queueRetry = task => {
const next = { attempts: 0, maxAttempts: 12, nextAt: Date.now() + 2 * 60 * 1000, ...task }
retryQueue.push(next)
try {
insertEmailLog.run({
skill: task.skillName || 'unknown',
alert_id: task.externalId || 'unknown',
subject: task.title || '',
status: 'retry_pending',
error_message: ''
})
} catch {}
}
const sendEmail = async (config, subject, text, skillName, alertId) => {
const emailReminderEnabled = config.email_reminder_enabled !== false
if (!emailReminderEnabled || !config.email || !config.email.enabled || !config.email.smtp) return true
// 解析 SMTP 配置中的 {{VAR}} 占位符(如 {{GMAIL_SMTP_PASS}})
const smtpCfg = JSON.parse(JSON.stringify(config.email.smtp))
if (smtpCfg.auth && smtpCfg.auth.pass) smtpCfg.auth.pass = resolvePlaceholders(smtpCfg.auth.pass)
let logId = 0
try {
const info = insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'sending', error_message: '' })
logId = Number(info.lastInsertRowid || 0)
} catch {}
try {
const transporter = nodemailer.createTransport(smtpCfg)
await transporter.sendMail({
from: config.email.from,
to: Array.isArray(config.email.to) ? config.email.to.join(',') : config.email.to,
subject,
text
})
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'sent', error_message: '' }) } catch {}
}
} catch (e) {
const errMsg = String(e.message || e)
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'failed', error_message: errMsg }) } catch {}
} else {
try { insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'failed', error_message: errMsg }) } catch {}
}
throw e
}
return true
}
const normalizeInlineText = text => String(text || '').replace(/\s+/g, ' ').trim()
const normalizeBodyForTitle = content => normalizeInlineText(String(content || '').replace(/\r?\n+/g, ';'))
const countHanChars = text => (String(text || '').match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length
const countDisplayChars = text => Array.from(String(text || '')).length
const takePrefixByHanLimit = (text, hanLimit) => {
const chars = Array.from(String(text || ''))
let han = 0
let out = ''
for (const ch of chars) {
const isHan = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(ch)
if (isHan) {
if (han >= hanLimit) break
han += 1
}
out += ch
}
return out.trim()
}
const applyTitlePolicy = (baseTitle, content) => {
const title = normalizeInlineText(baseTitle)
const detail = normalizeBodyForTitle(content)
if (!detail) return { title, marker: TITLE_POLICY_MARKER }
const hanCount = countHanChars(detail)
const effectiveCount = hanCount > 0 ? hanCount : countDisplayChars(detail)
if (effectiveCount <= TITLE_INLINE_HAN_LIMIT) {
return { title: normalizeInlineText(`${title} ${detail}`), marker: TITLE_POLICY_MARKER }
}
const prefix = hanCount > 0 ? takePrefixByHanLimit(detail, TITLE_INLINE_HAN_LIMIT) : Array.from(detail).slice(0, TITLE_INLINE_HAN_LIMIT).join('')
const remaining = Math.max(0, effectiveCount - TITLE_INLINE_HAN_LIMIT)
return { title: normalizeInlineText(`${title} ${prefix}【剩余${remaining}汉字,详见正文】`), marker: TITLE_POLICY_MARKER }
}
const buildWeeklyPayload = ({ target, title, content, externalId, dateStr, titlePolicy }) => ({
key: target.key || process.env.SYSTEM_AUTH_PASS_93220 || '',
source: 'market_skills',
external_id: externalId,
title,
content,
title_policy: titlePolicy || TITLE_POLICY_MARKER,
status_code: 'new',
status_label: '📅本周新增',
date: dateStr
})
const buildCalendarPayload = ({ title, content, eventId, tsLocal }) => ({
id: eventId,
title,
content,
start: tsLocal,
end: tsLocal,
remindAt: tsLocal,
allDay: 0
})
const pushTargets = async (config, payloadBuilder) => {
const calendarReminderEnabled = config.calendar_reminder_enabled !== false
const targets = Array.isArray(config.push_targets) ? [...config.push_targets] : []
if (config.push_api && config.push_api.enabled && config.push_api.url) targets.push(config.push_api)
for (const target of targets) {
if (!target.enabled) continue
const url = resolveUrl(target.url)
if (!url) continue
let payload = null
if (url.includes('/api/weekly/ext/event')) {
payload = payloadBuilder('weekly', target)
} else if (url.includes('/api/calendar/events')) {
if (!calendarReminderEnabled) continue
payload = payloadBuilder('calendar', target)
} else {
payload = payloadBuilder('generic', target)
}
if (!payload) continue
await axios.post(url, payload, { timeout: 15000 })
}
}
const sendDispatch = async ({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow = false, enqueueOnFail = true }) => {
const content = lines.join('\n')
const titlePack = applyTitlePolicy(title, content)
const finalTitle = titlePack.title
const signature = `${finalTitle}\n${content}\n${externalId}\n${tsLocal}\n${sendEmailNow ? '1' : '0'}`
const prev = lastDispatchSignature.get(externalId)
if (prev === signature) return true
lastDispatchSignature.set(externalId, signature)
try {
if (sendEmailNow) {
await sendEmail(config, finalTitle, content, skillName, externalId)
logJSON('skills.alert.email.sent', { skill: skillName, externalId }, 'market_skills')
}
await pushTargets(config, (type, target) => {
const dateStr = tsLocal.slice(0, 10)
if (type === 'weekly') return buildWeeklyPayload({ target, title: finalTitle, content, externalId, dateStr, titlePolicy: titlePack.marker })
if (type === 'calendar') return buildCalendarPayload({ title: finalTitle, content, eventId: externalId, tsLocal })
return {
source: 'market_skills',
skill: skillName,
external_id: externalId,
title: finalTitle,
content,
title_policy: titlePack.marker,
ts: Date.now()
}
})
logJSON('skills.alert.dispatch.sent', { skill: skillName, externalId, tsLocal, title: finalTitle, title_policy: titlePack.marker }, 'market_skills')
return true
} catch (e) {
logJSON('skills.alert.dispatch.error', { skill: skillName, externalId, error: String(e.message || e) }, 'market_skills')
if (enqueueOnFail) queueRetry({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow })
return false
}
}
const processRetryQueue = async () => {
if (retryQueue.length === 0) return
const nowMs = Date.now()
const pending = []
for (const job of retryQueue.splice(0, retryQueue.length)) {
if (job.nextAt > nowMs) {
pending.push(job)
continue
}
const ok = await sendDispatch({ ...job, enqueueOnFail: false })
if (!ok) {
job.attempts = Number(job.attempts || 0) + 1
if (job.attempts < Number(job.maxAttempts || 12)) {
const delay = Math.min(60 * 60 * 1000, Math.pow(2, job.attempts) * 60 * 1000)
job.nextAt = Date.now() + delay
pending.push(job)
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_attempt',
error_message: `attempt ${job.attempts}/${job.maxAttempts}`
})
} catch {}
} else {
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_exhausted',
error_message: `gave up after ${job.attempts} attempts`
})
} catch {}
logJSON('skills.alert.retry.giveup', { skill: job.skillName, externalId: job.externalId }, 'market_skills')
}
}
}
retryQueue.push(...pending)
}
// Helper to parse time string to ms
const parseInterval = (str) => {
if (typeof str === 'number') return str
if (!str) return 60000 // Default 1 min
const m = str.match(/^(\d+)([smh])$/)
if (!m) return 60000
const val = parseInt(m[1])
const unit = m[2]
if (unit === 's') return val * 1000
if (unit === 'm') return val * 60000
if (unit === 'h') return val * 3600000
return 60000
}
const insertQuote = db.prepare(
`INSERT INTO market_quotes (label, symbol, source, name, price, prev_close, change, change_percent)
VALUES (@label, @symbol, @source, @name, @price, @prevClose, @change, @change_percent)`
)
const saveQuotes = (quotes) => {
if (!Array.isArray(quotes)) return
const tx = db.transaction((list) => {
for (const q of list) {
try {
insertQuote.run(q)
} catch (e) {
logJSON('skills.db.error', { error: String(e.message), symbol: q.symbol }, 'market_skills')
}
}
})
tx(quotes)
}
const persistRegularSlotDedup = (skillName, alerts) => {
const p = bjParts()
const slot = p.hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${p.dateStr}-${slot}`
const filtered = []
const updates = []
for (const alert of (alerts || [])) {
const alertId = alert.id || alert.title
const row = getAlertSlot.get(skillName, alertId)
if (row && row.slot_key === currentSlotKey) continue
filtered.push(alert)
updates.push({ skill: skillName, alert_id: alertId, slot_key: currentSlotKey })
}
if (updates.length > 0) {
const tx = db.transaction(list => {
for (const row of list) upsertAlertSlot.run(row)
})
tx(updates)
}
return filtered
}
const buildAlertRows = (skillName, alerts, dataTime) => {
const lineTs = String(dataTime || bjNowDateTime())
return (alerts || []).map(a => ({
skill: skillName,
alertId: a.id || a.title || '',
title: String(a.title || ''),
message: String(a.message || ''),
ts: lineTs
}))
}
const getChinaSlotAndTime = (policy, parts) => {
const slot = parts.hour < 12 ? 'am' : 'pm'
const hhmm = slot === 'am' ? policy.chinaAmReminderTime : policy.chinaPmReminderTime
return { slot, hhmm }
}
const makeBucketLines = rows => dedupeByAlert(rows).map(r => `【${String(r.ts || bjNowDateTime())}】 [${r.skill}] ${r.title}: ${r.message}`)
const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, dataTime }) => {
const isTradingDate = await isChinaTradingDate(dateStr)
if (!isTradingDate) {
logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills')
return
}
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const { slot, hhmm } = getChinaSlotAndTime(policy, parts)
const key = `skills.bucket.cn.${dateStr}.${slot}`
const prev = readStateJson(key, [])
const merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `中国证券${slot === 'am' ? '上午' : '下午'}汇总 ${dateStr}`
const externalId = `skill-summary-cn-${dateStr}-${slot}`
const tsLocal = localTs(dateStr, hhmm)
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => {
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const dayState = getDayState(dateStr)
const sid = safeSkillId(skillId)
const skillSentCount = Number(dayState.nonChinaSkills[sid] || 0)
if (skillSentCount < policy.nonChinaPerSkillDailyCap && dayState.nonChinaTotal >= policy.nonChinaDailyCap) {
const nextDate = nextDateStr(dateStr)
appendDeferred(nextDate, rows)
logJSON('skills.non_cn.deferred', { skill: skillName, date: dateStr, nextDate, count: rows.length }, 'market_skills')
return
}
if (skillSentCount < policy.nonChinaPerSkillDailyCap) {
dayState.nonChinaSkills[sid] = skillSentCount + 1
dayState.nonChinaTotal = Number(dayState.nonChinaTotal || 0) + 1
if (!dayState.nonChinaFirstSent) dayState.nonChinaFirstSent = true
setDayState(dateStr, dayState)
}
const key = `skills.bucket.non_cn.${dateStr}.${sid}`
const prev = readStateJson(key, [])
let merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) {
const deferred = getDeferredList(dateStr)
if (Array.isArray(deferred) && deferred.length > 0) {
merged = dedupeByAlert([...deferred, ...merged])
setDeferredList(dateStr, [])
}
}
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `非中国证券汇总 ${dateStr} ${skillName}`
const externalId = `skill-summary-noncn-${dateStr}-${sid}`
const tsLocal = localTs(dateStr, '21:30')
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleUrgentAlerts = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, absChange, dataTime }) => {
if (!Number.isFinite(absChange) || absChange < policy.urgentAbsChangePercent) return []
const cooldownState = readStateJson('skills.urgent_cooldown', {})
const nowMs = Date.now()
const cooldownMs = policy.urgentCooldownMinutes * 60 * 1000
const sendList = []
for (const a of alerts) {
const aid = a.id || a.title || 'unknown'
const k = `${skillName}::${aid}`
const last = Number(cooldownState[k] || 0)
if (nowMs - last < cooldownMs) continue
cooldownState[k] = nowMs
sendList.push(a)
}
writeStateJson('skills.urgent_cooldown', cooldownState)
if (sendList.length === 0) return []
const rows = buildAlertRows(skillName, sendList, dataTime)
addSummaryRows(dateStr, rows, true)
const lines = makeBucketLines(rows)
const windowBase = Math.floor(nowMs / cooldownMs)
const ext = `skill-urgent-${safeSkillId(skillId)}-${dateStr}-${windowBase}`
const tsLocal = localTs(dateStr, `${pad2(parts.hour)}:${pad2(parts.minute)}`)
const title = `严重告警 ${skillName} | 波动 ${absChange.toFixed(2)}%`
await sendDispatch({ skillId, skillName, config, title, lines, externalId: ext, tsLocal, sendEmailNow: true })
return sendList
}
const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts }) => {
const dayState = getDayState(dateStr)
if (dayState.dailySummarySent) return
const cut = parseHHMM(policy.dailySummaryTime)
if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return
const pool = getSummaryPool(dateStr)
const regular = dedupeByAlert(pool.regular || [])
const urgent = dedupeByAlert(pool.urgent || [])
if (regular.length === 0 && urgent.length === 0) {
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
return
}
const lines = []
if (urgent.length > 0) {
lines.push('【严重告警】')
lines.push(...makeBucketLines(urgent))
}
if (regular.length > 0) {
if (lines.length > 0) lines.push('')
lines.push('【日终汇总】')
lines.push(...makeBucketLines(regular))
}
const title = `市场日终总览 ${dateStr}`
const externalId = `skill-daily-summary-${dateStr}`
const tsLocal = localTs(dateStr, policy.dailySummaryTime)
await sendDispatch({ skillId: 'daily_summary', skillName: '市场日终总览', config, title, lines, externalId, tsLocal, sendEmailNow: true })
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
}
const EXCLUDED_FROM_PUSH_SKILLS = new Set([
'中国银行-299法',
'中国神华-299法',
'巴菲特指数',
'美国战争指数',
'流动性状态机'
])
const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => {
if (!rawAlerts || rawAlerts.length === 0) return
if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return
const p = bjParts()
const dateStr = p.dateStr
const policy = getPolicy(config)
const quote = Array.isArray(dataRows) && dataRows.length > 0 ? dataRows[0] : null
const explain = quote && typeof quote.explain === 'string' ? quote.explain.trim() : ''
const alertsWithExplain = explain
? rawAlerts.map(a => ({ ...a, message: `${String(a.message || '').trim()}\n说明:${explain}` }))
: rawAlerts
const absChange = quote && Number.isFinite(Number(quote.change_percent)) ? Math.abs(Number(quote.change_percent)) : NaN
const dataTime = bjNowDateTime()
const urgentDispatched = await handleUrgentAlerts({ skillId, skillName, config, alerts: alertsWithExplain, policy, dateStr, parts: p, absChange, dataTime })
const urgentKeySet = new Set(urgentDispatched.map(a => a.id || a.title || ''))
const regularCandidates = alertsWithExplain.filter(a => !urgentKeySet.has(a.id || a.title || ''))
const regularAlerts = persistRegularSlotDedup(skillName, regularCandidates)
if (regularAlerts.length > 0) {
if (isChinaSecuritySkill(config)) {
await handleChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, parts: p, dataTime })
} else {
await handleNonChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, dataTime })
}
}
await maybeSendDailySummary({ skillName, config, policy, dateStr, parts: p })
}
const runSkill = async (id, skillModule, config) => {
logJSON('skills.run.start', { skill: id }, 'market_skills')
try {
if (config && config.display === false) return
const context = {
logJSON: (evt, data) => logJSON(evt, data, `skill:${id}`),
axios,
db
}
const result = await skillModule.run(context, config)
if (!result) {
// Placeholder card when skill returns nothing (all data sources failed)
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: '⚠ 数据源不可用(所有源均无响应)'
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
return
}
let { data, alerts } = result
if (data && Array.isArray(data)) {
const extra = {}
if (config.url) extra.url = config.url
if (Number.isFinite(config.card_order)) extra.card_order = config.card_order
if (config.card_type) extra.card_type = config.card_type
if (Number.isFinite(config.card_span) && config.card_span >= 1) extra.card_span = Math.floor(config.card_span)
if (Object.keys(extra).length > 0) {
data = data.map(item => ({ ...item, ...extra }))
}
}
if (data && data.length > 0) {
const skillEntry = skills.get(id)
if (skillEntry) {
skillEntry.latestData = data
}
setLatestSkillDataState(id, data)
saveQuotes(data)
logJSON('skills.data.saved', { skill: id, count: data.length }, 'market_skills')
}
if (alerts && alerts.length > 0) await sendAlerts(id, config.name || id, config, alerts, data)
const p = bjParts()
await maybeSendDailySummary({ skillName: config.name || id, config, policy: getPolicy(config), dateStr: p.dateStr, parts: p })
await processRetryQueue()
} catch (e) {
logJSON('skills.run.error', { skill: id, error: String(e.message || e) }, 'market_skills')
// Placeholder card when skill throws
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: `⚠ 数据异常:${String(e.message || e).slice(0, 80)}`
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
}
}
const loadSkills = () => {
if (!fs.existsSync(SKILLS_DIR)) return
const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const dirent of dirs) {
if (!dirent.isDirectory()) continue
const id = dirent.name
const skillPath = path.join(SKILLS_DIR, id)
const indexFile = path.join(skillPath, 'index.js')
const configFile = path.join(skillPath, 'config.json')
if (fs.existsSync(indexFile) && fs.existsSync(configFile)) {
try {
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
if (config.enabled === false) continue
if (config.display === false) continue
const skillModule = require(indexFile)
const interval = parseInterval(config.schedule)
logJSON('skills.loaded', { skill: id, schedule: config.schedule, interval }, 'market_skills')
const entry = { timer: null, config, module: skillModule, latestData: getLatestSkillDataState(id) }
skills.set(id, entry)
runSkill(id, skillModule, config)
const timer = setInterval(() => {
runSkill(id, skillModule, config)
}, interval)
entry.timer = timer
} catch (e) {
logJSON('skills.load.error', { skill: id, error: String(e.message) }, 'market_skills')
}
}
}
}
const getLatestData = () => {
const all = []
for (const [id, entry] of skills) {
if (entry.latestData && Array.isArray(entry.latestData)) {
all.push(...entry.latestData)
}
}
return all
}
module.exports = {
start: () => {
loadSkills()
if (!retryTimer) {
retryTimer = setInterval(() => { processRetryQueue().catch(() => {}) }, 60 * 1000)
}
logJSON('skills.manager.started', { count: skills.size }, 'market_skills')
},
getLatestData
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "纳斯达克",
"card_order": 13,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usIXIC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usIXIC",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "纳指"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -0,0 +1,156 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "日经225指数",
"card_order": 9,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWJ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWJ",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Tokyo",
"sessions": [
{
"start": "09:00",
"end": "11:30"
},
{
"start": "12:30",
"end": "15:00"
}
],
"display_label": "日经225"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,42 @@
{
"name_comment": "显示的名称",
"name": "MVREMX 稀土指数(REMX)",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是否启用该监控 (true/false)",
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule_comment": "刷新频率 (如 10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代码",
"symbol": "usREMX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usREMX",
"source_comment": "数据来源",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配置",
"alerts": {
"change_percent_down_comment": "跌幅预警阈值",
"change_percent_down": -2,
"change_percent_up_comment": "涨幅预警阈值",
"change_percent_up": 2
},
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列表",
"push_targets": [],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "美股REMX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "沙特ETF(富时沙特)",
"card_order": 17,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usFLSA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usFLSA",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "富时沙特"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "上证指数",
"card_order": 1,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000001",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "上证"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,77 @@
{
"name_comment": "显示的名",
"name": "中国神华-299",
"card_order": 1,
"card_type": "299",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": false,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh601088_299",
"source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601088",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601088.html",
"source_comment": "数据来源 (",
"source": "tencent",
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "中国A"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,201 @@
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const fetchKLine = async (axios, symbol) => {
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
const d = res.data.data[symbol]
if (!d) return null
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
return null
}
}
const buildAlerts = ({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
}) => {
const alerts = []
const addAlert = (id, title, message, level = 'warning') => {
alerts.push({ id, title, message, level })
}
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isCrossDownMA250 = isCrossDown(prevClose, prevMA250, latestClose, ma250)
const isCrossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
if (isCrossDownMA250) {
addAlert('below_ma250', '中国神华跌破年线!立即停止加仓,建议减仓', '中国神华跌破年线!立即停止加仓,建议减仓', 'error')
}
if (isCrossUpMA250) {
addAlert('above_ma250', '中国神华回到年线之上,可恢复加仓策略', '中国神华回到年线之上,可恢复加仓策略')
}
if (!isBelowMA250) {
if (isCrossDown(prevClose, prevMA20, latestClose, ma20)) {
addAlert('below_ma20', '中国神华-跌破20日线,需加仓 20%', '中国神华-跌破20日线,需加仓 20%')
}
if (isCrossDown(prevClose, prevMA60, latestClose, ma60)) {
addAlert('below_ma60', '中国神华-跌破60日线,需加仓 40%', '中国神华-跌破60日线,需加仓 40%')
}
if (isCrossDown(prevClose, prevMA90, latestClose, ma90)) {
addAlert('below_ma90', '中国神华-跌破90日线,需加仓剩余 40%', '中国神华-跌破90日线,需加仓剩余 40%')
}
}
if (isCrossUp(prevClose, prevMA20, latestClose, ma20)) {
addAlert('above_ma20', '中国神华-站上20日线,需卖出 20%', '中国神华-站上20日线,需卖出 20%')
}
if (isCrossUp(prevClose, prevMA60, latestClose, ma60)) {
addAlert('above_ma60', '中国神华-站上60日线,需卖出 40%', '中国神华-站上60日线,需卖出 40%')
}
if (isCrossUp(prevClose, prevMA90, latestClose, ma90)) {
addAlert('above_ma90', '中国神华-站上90日线,需卖出剩余 40%', '中国神华-站上90日线,需卖出剩余 40%')
}
if (ma10 > ma20) {
addAlert('trend_bull', '趋势提示', '中国神华处于上涨趋势,建议持有底仓 + 已建波段仓,不做 T')
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol
const sourceSymbol = config.source_symbol || 'sh601088'
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.shenhua_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
const closes = klineData.map(d => d.close)
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const latestDate = klineData[klineData.length - 1].date
const ma10 = calculateMA(closes, 10)
const ma20 = calculateMA(closes, 20)
const ma60 = calculateMA(closes, 60)
const ma90 = calculateMA(closes, 90)
const ma250 = calculateMA(closes, 250)
const closesPrev = closes.slice(0, -1)
const prevMA20 = calculateMA(closesPrev, 20)
const prevMA60 = calculateMA(closesPrev, 60)
const prevMA90 = calculateMA(closesPrev, 90)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
logJSON('skill.shenhua_299.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const trendText = ma10 > ma20 ? '多头排列' : '非多头'
const yearLineText = latestClose < ma250 ? '年线下方' : '年线之上'
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const crossDownMA20 = isCrossDown(prevClose, prevMA20, latestClose, ma20)
const crossDownMA60 = isCrossDown(prevClose, prevMA60, latestClose, ma60)
const crossDownMA90 = isCrossDown(prevClose, prevMA90, latestClose, ma90)
const crossUpMA20 = isCrossUp(prevClose, prevMA20, latestClose, ma20)
const crossUpMA60 = isCrossUp(prevClose, prevMA60, latestClose, ma60)
const crossUpMA90 = isCrossUp(prevClose, prevMA90, latestClose, ma90)
const crossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
const statusDownMA20 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA20 ? '已触发(加仓20%)' : '未触发')
const statusDownMA60 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA60 ? '已触发(加仓40%)' : '未触发')
const statusDownMA90 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发')
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}<br/>
MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}<br/>
趋势:${trendText} | 年线状态:${yearLineText}
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}<br/>
60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}<br/>
90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}<br/>
MA10>MA20:${statusTrend} | MA250:${statusMA250}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: sourceSymbol,
isMaCard: true,
render_type: 'html',
html: htmlPayload,
ma10: ma10.toFixed(2),
ma20: ma20.toFixed(2),
ma60: ma60.toFixed(2),
ma90: ma90.toFixed(2),
ma250: ma250.toFixed(2),
source: config.source || 'tencent',
name: config.name,
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${trendText} (${latestDate})`
}]
const alerts = buildAlerts({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
})
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "东南亚科技(海峡时报)",
"card_order": 20,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usASEA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usASEA",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Singapore",
"sessions": [
{
"start": "09:00",
"end": "17:00"
}
],
"display_label": "东南亚STI"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "标普500",
"card_order": 12,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usINX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usINX",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "标普500"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -0,0 +1,156 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "科创50",
"card_order": 4,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "科创50"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "深证成指",
"card_order": 2,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sz399001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399001",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "深证"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "宝岛加权指数",
"card_order": 87,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWT",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWT",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Taipei",
"sessions": [
{
"start": "09:00",
"end": "13:30"
}
],
"display_label": "台湾"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}

Some files were not shown because too many files have changed in this diff Show More