feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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`) })
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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, '<').replace(/>/g, '>')}</td><td>${(it.remark || '').replace(/</g, '<').replace(/>/g, '>')}</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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
Binary file not shown.
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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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
|
||||
};
|
||||
@@ -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,'<').replace(/>/g,'>')}</td><td>${(it.remark||'').replace(/</g,'<').replace(/>/g,'>')}</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
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user