chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取
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 }
|
||||
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,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,802 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// 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
+5941
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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(iShares MSCI Brazil ETF)",
|
||||
"symbol": "usEWZ",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usEWZ",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": false,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "bj899050",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/bj899050",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "å�¡ç‰‡æŽ’åº�åº�å�·ï¼Œè¶Šå¤§è¶Šé� å�Žï¼ˆå½“å‰�最大å€?87,设ä¸?88 排最å�Žï¼‰",
|
||||
"card_order": 88,
|
||||
"card_type_comment": "�片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
|
||||
"card_type": "overseas",
|
||||
"card_span_comment": "å�¡ç‰‡æ¨ªå�‘å� ä½�列数ï¼?为默认,2为跨两列",
|
||||
"card_span": 2,
|
||||
"enabled_comment": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�1h 表示1�时)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(科创50,原ä¸éŸ©å�Šå¯¼ä½“æŒ‡æ•°æ— ETF替代)",
|
||||
"symbol": "sh000688",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sh000688",
|
||||
"source_comment": "数���",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "sz399006",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sz399006",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"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": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"price_min_comment": "æœ€ä½Žä»·æ ¼é¢„è?,
|
||||
"price_min": 4800,
|
||||
"price_max_comment": "æœ€é«˜ä»·æ ¼é¢„è?,
|
||||
"price_max": 5500,
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "hf_SI",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"price_min_comment": "æœ€ä½Žä»·æ ¼é¢„è?,
|
||||
"price_min": 75,
|
||||
"price_max_comment": "æœ€é«˜ä»·æ ¼é¢„è?,
|
||||
"price_max": 95,
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "sh000300",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sh000300",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "usDJI",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usDJI",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(iShares MSCI Germany ETF)",
|
||||
"symbol": "usEWG",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usEWG",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "hkHSI",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/hkHSI",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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(�ETF",
|
||||
"card_order": 11,
|
||||
"card_type": "overseas",
|
||||
"enabled_comment": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(India Nifty 50 ETF)",
|
||||
"symbol": "usNFTY",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usNFTY",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(iShares MSCI South Korea ETF)",
|
||||
"symbol": "usEWY",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usEWY",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "å�¡ç‰‡æ¨ªå�‘å� ä½�列数ï¼?为默认,2为跨两列",
|
||||
"card_span": 2,
|
||||
"enabled_comment": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "6h",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "us_liquidity_state",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://fred.stlouisfed.org/series/FEDFUNDS",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "fred",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "usIXIC",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usIXIC",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(iShares MSCI Japan ETF)",
|
||||
"symbol": "usEWJ",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usEWJ",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(Franklin FTSE Saudi Arabia ETF)",
|
||||
"symbol": "usFLSA",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usFLSA",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "sh000001",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sh000001",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": false,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"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": "数��� (�tencent)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(Global X 东å�—äº?ETF)",
|
||||
"symbol": "usASEA",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usASEA",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "usINX",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usINX",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "sh000688",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sh000688",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(腾讯财ç»�或其他æº�)",
|
||||
"symbol": "sz399001",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/sz399001",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "tencent",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(iShares MSCI Taiwan ETF)",
|
||||
"symbol": "usEWT",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://gu.qq.com/usEWT",
|
||||
"source_comment": "数��� (�tencent)",
|
||||
"source": "yahoo_probe",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"change_percent_down_comment": "跌幅预è¦é˜ˆå€?(负数,如 -2 表示è·?2%)",
|
||||
"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": "涨幅预è¦é˜ˆå€?(æ£æ•°ï¼Œå¦‚ 2 表示æ¶?2%)",
|
||||
"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",
|
||||
\"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."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('../germany_etf/index.js')
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name_comment": "显示的��,
|
||||
"name": "美国战争指数",
|
||||
"card_order": 14,
|
||||
"card_type": "overseas",
|
||||
"enabled_comment": "是��用该监�(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "是å�¦æ˜¾ç¤ºè¯¥å�¡ç‰?(false 则ä¸�拉å�–æ•°æ�®ä¹Ÿä¸�å°�å˜)",
|
||||
"display": true,
|
||||
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "监控的代ç ?(技能内部定ä¹?",
|
||||
"symbol": "us_war_pizzint",
|
||||
"url_comment": "点击å�¡ç‰‡å�³ä¸Šè§’房å�å›¾æ ‡è·³è½¬çš„åœ°å�€",
|
||||
"url": "https://fred.stlouisfed.org/series/VIXCLS",
|
||||
"stable_url_comment": "稳定数��地�(优先)",
|
||||
"stable_url": "https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS",
|
||||
"pizzint_url_comment": "备用数��地�(兜底)",
|
||||
"pizzint_url": "https://www.pizzint.watch/",
|
||||
"fail_alert_consecutive_comment": "连ç»å¤±è´¥è¾¾åˆ°è¯¥æ¬¡æ•°æ‰�触å�‘异常告è¦",
|
||||
"fail_alert_consecutive": 3,
|
||||
"source_comment": "数���",
|
||||
"source": "fred_vix",
|
||||
"alerts_comment": "预è¦é˜ˆå€¼é…�ç½?,
|
||||
"alerts": {
|
||||
"doughcon_critical_max_comment": "DOUGHCON å°�于ç‰äºŽè¯¥å€¼è§¦å�‘高风险预è¦",
|
||||
"doughcon_critical_max": 2,
|
||||
"doughcon_warning_max_comment": "DOUGHCON å°�于ç‰äºŽè¯¥å€¼è§¦å�‘风险抬å�‡é¢„è?,
|
||||
"doughcon_warning_max": 3,
|
||||
"vix_warning_min_comment": "VIX 大于ç‰äºŽè¯¥å€¼è§¦å�‘风险抬å�‡é¢„è?,
|
||||
"vix_warning_min": 22,
|
||||
"vix_critical_min_comment": "VIX 大于ç‰äºŽè¯¥å€¼è§¦å�‘高风险预è¦",
|
||||
"vix_critical_min": 30,
|
||||
"index_jump_threshold_comment": "战争指数å�•次上跳达到该值触å�‘快速å�‡æ¸©é¢„è?,
|
||||
"index_jump_threshold": 20,
|
||||
"urgent_abs_change_percent_comment": "严é‡�告è¦é˜ˆå€?(ç»�对波动百分æ¯?",
|
||||
"urgent_abs_change_percent": 5,
|
||||
"urgent_cooldown_minutes_comment": "严é‡�告è¦å†·å�´åˆ†é’Ÿæ•?,
|
||||
"urgent_cooldown_minutes": 120
|
||||
},
|
||||
"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",
|
||||
\"key\": \"\"
|
||||
},
|
||||
{
|
||||
"url_comment": "推�接�地�",
|
||||
"url": "http://localhost:{{PORT}}/api/calendar/events",
|
||||
"enabled_comment": "是��用",
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"market_status": {
|
||||
"timezone": "America/New_York",
|
||||
"sessions": [
|
||||
{
|
||||
"start": "00:00",
|
||||
"end": "23:59"
|
||||
}
|
||||
],
|
||||
"display_label": "FRED VIX"
|
||||
},
|
||||
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
const fetchText = async (axios, url, headers = {}) => {
|
||||
try {
|
||||
const res = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
headers
|
||||
})
|
||||
if (!res || res.status >= 400) return null
|
||||
return String(res.data || '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPizzint = (axios, url) => fetchText(axios, url, {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Referer': 'https://www.pizzint.watch/',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
})
|
||||
|
||||
const fetchFredVix = (axios, url) => fetchText(axios, url, {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'text/csv,text/plain;q=0.9,*/*;q=0.8'
|
||||
})
|
||||
|
||||
const buildCandidateUrls = (url) => {
|
||||
const primary = String(url || '').trim() || 'https://www.pizzint.watch/'
|
||||
const list = [primary]
|
||||
try {
|
||||
const u = new URL(primary)
|
||||
const host = String(u.hostname || '').toLowerCase()
|
||||
if (host === 'www.pizzint.watch') {
|
||||
list.push('https://pizzint.watch/')
|
||||
} else if (host === 'pizzint.watch') {
|
||||
list.push('https://www.pizzint.watch/')
|
||||
}
|
||||
} catch {}
|
||||
list.push('https://r.jina.ai/http://www.pizzint.watch/')
|
||||
list.push('https://r.jina.ai/http://pizzint.watch/')
|
||||
return [...new Set(list)]
|
||||
}
|
||||
|
||||
const stripHtmlToText = (raw) => String(raw || '')
|
||||
.replace(/<script[\s\S]*?<\/script>/ig, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/ig, ' ')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const parsePizzint = (raw) => {
|
||||
const html = String(raw || '')
|
||||
const text = stripHtmlToText(html)
|
||||
const doughconMatch =
|
||||
text.match(/DOUGHCON\s*([1-5])/i) ||
|
||||
html.match(/DOUGHCON\s*([1-5])/i) ||
|
||||
text.match(/"doughcon"\s*:\s*([1-5])/i) ||
|
||||
text.match(/"level"\s*:\s*([1-5])/i)
|
||||
if (!doughconMatch) return null
|
||||
const doughcon = Number(doughconMatch[1])
|
||||
if (!Number.isFinite(doughcon)) return null
|
||||
|
||||
const statusMatch =
|
||||
text.match(/STATUS:\s*([A-Z_]+)/i) ||
|
||||
text.match(/\bSTATUS\b\s*:?\s*([A-Z][A-Z _-]{2,24})\b/i) ||
|
||||
html.match(/STATUS:\s*(?:<\/[^>]+>\s*<[^>]+>\s*)*([A-Z][A-Z _-]{2,24})\s*</i) ||
|
||||
text.match(/"status"\s*:\s*"([A-Z_]+)"/i)
|
||||
const status = statusMatch ? String(statusMatch[1]).trim().replace(/\s+/g, '_').toUpperCase() : 'UNKNOWN'
|
||||
|
||||
const locationsMatch =
|
||||
text.match(/(\d+)\s*LOCATIONS?\s*MONITORED/i) ||
|
||||
text.match(/"locationsMonitored"\s*:\s*(\d+)/i) ||
|
||||
text.match(/"locations"\s*:\s*(\d+)/i)
|
||||
const locations = locationsMatch ? Number(locationsMatch[1]) : null
|
||||
|
||||
const timeMatch =
|
||||
text.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/) ||
|
||||
text.match(/"timestamp"\s*:\s*"([0-9T:\-.Z]+)"/i)
|
||||
const capturedAt = timeMatch
|
||||
? String(timeMatch[1]).replace('T', ' ').replace('Z', '').replace(/\s+/, ' ')
|
||||
: new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
|
||||
const feedFailed = /CONNECTION FAILED|Data fetch error|ERROR:\s*Failed to fetch|Failed to fetch/i.test(text)
|
||||
const warIndex = Math.max(0, Math.min(100, (6 - doughcon) * 20))
|
||||
|
||||
return { doughcon, status, locations, capturedAt, feedFailed, warIndex }
|
||||
}
|
||||
|
||||
const clamp = (n, min, max) => Math.max(min, Math.min(max, n))
|
||||
|
||||
const vixToWarIndex = (vix) => {
|
||||
const base = ((vix - 12) / 38) * 100
|
||||
return clamp(base, 0, 100)
|
||||
}
|
||||
|
||||
const classifyVixStatus = (vix) => {
|
||||
if (vix >= 30) return 'STRESSED'
|
||||
if (vix >= 22) return 'ELEVATED'
|
||||
return 'CALM'
|
||||
}
|
||||
|
||||
const parseFredVix = (raw) => {
|
||||
const lines = String(raw || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||
if (lines.length < 2) return null
|
||||
const points = []
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
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()
|
||||
const value = Number(valueRaw)
|
||||
if (!date || !Number.isFinite(value)) continue
|
||||
points.push({ date, value })
|
||||
}
|
||||
if (!points.length) return null
|
||||
const last = points[points.length - 1]
|
||||
const prev = points.length > 1 ? points[points.length - 2] : null
|
||||
const warIndex = vixToWarIndex(last.value)
|
||||
const prevWarIndex = prev ? vixToWarIndex(prev.value) : warIndex
|
||||
return {
|
||||
provider: 'fred_vix',
|
||||
vix: last.value,
|
||||
prevVix: prev ? prev.value : last.value,
|
||||
warIndex,
|
||||
prevWarIndex,
|
||||
status: classifyVixStatus(last.value),
|
||||
capturedAt: `${last.date} 00:00:00`
|
||||
}
|
||||
}
|
||||
|
||||
const getWarRiskBand = (warIndex) => {
|
||||
if (!Number.isFinite(warIndex)) return '未知'
|
||||
if (warIndex < 30) return '低风险'
|
||||
if (warIndex < 60) return '中风险'
|
||||
return '高风险'
|
||||
}
|
||||
|
||||
const buildExplainText = (snapshot, sourceLabel) => {
|
||||
const warIndex = Number(snapshot && snapshot.warIndex)
|
||||
const band = getWarRiskBand(warIndex)
|
||||
if (snapshot && snapshot.provider === 'fred_vix') {
|
||||
const vix = Number(snapshot.vix)
|
||||
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},VIX ${vix.toFixed(2)} 代表市场波动预期。`
|
||||
}
|
||||
const doughcon = Number(snapshot && snapshot.doughcon)
|
||||
const status = String((snapshot && snapshot.status) || 'UNKNOWN')
|
||||
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},DOUGHCON ${doughcon} / 状态 ${status}。`
|
||||
}
|
||||
|
||||
const buildAlerts = (snapshot, prevIndex, config) => {
|
||||
const alerts = []
|
||||
const cfg = config && config.alerts ? config.alerts : {}
|
||||
const jumpThreshold = Number.isFinite(cfg.index_jump_threshold) ? cfg.index_jump_threshold : 20
|
||||
const warIndex = snapshot.warIndex
|
||||
const delta = Number.isFinite(prevIndex) ? warIndex - prevIndex : 0
|
||||
|
||||
if (snapshot.provider === 'fred_vix') {
|
||||
const vixWarningMin = Number.isFinite(cfg.vix_warning_min) ? cfg.vix_warning_min : 22
|
||||
const vixCriticalMin = Number.isFinite(cfg.vix_critical_min) ? cfg.vix_critical_min : 30
|
||||
if (snapshot.vix >= vixCriticalMin) {
|
||||
alerts.push({
|
||||
id: `vix_critical_${Math.floor(snapshot.vix)}`,
|
||||
title: `${config.name}高风险预警`,
|
||||
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
|
||||
level: 'critical'
|
||||
})
|
||||
} else if (snapshot.vix >= vixWarningMin) {
|
||||
alerts.push({
|
||||
id: `vix_warning_${Math.floor(snapshot.vix)}`,
|
||||
title: `${config.name}风险抬升预警`,
|
||||
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
|
||||
level: 'warning'
|
||||
})
|
||||
}
|
||||
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
|
||||
alerts.push({
|
||||
id: `jump_${Math.floor(delta)}`,
|
||||
title: `${config.name}快速升温预警`,
|
||||
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
|
||||
level: 'critical'
|
||||
})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
const criticalMax = Number.isFinite(cfg.doughcon_critical_max) ? cfg.doughcon_critical_max : 2
|
||||
const warningMax = Number.isFinite(cfg.doughcon_warning_max) ? cfg.doughcon_warning_max : 3
|
||||
const doughcon = snapshot.doughcon
|
||||
const status = snapshot.status
|
||||
if (snapshot.feedFailed) {
|
||||
alerts.push({
|
||||
id: 'feed_failed',
|
||||
title: `${config.name}数据源异常`,
|
||||
message: `PizzINT 页面出现数据抓取失败信号,请人工复核。当前 DOUGHCON ${doughcon}`,
|
||||
level: 'warning'
|
||||
})
|
||||
}
|
||||
if (status && status !== 'OPERATIONAL' && status !== 'UNKNOWN') {
|
||||
alerts.push({
|
||||
id: `status_${status.toLowerCase()}`,
|
||||
title: `${config.name}运行状态异常`,
|
||||
message: `当前状态 ${status},DOUGHCON ${doughcon},指数 ${warIndex.toFixed(2)}`,
|
||||
level: 'warning'
|
||||
})
|
||||
}
|
||||
if (doughcon <= criticalMax) {
|
||||
alerts.push({
|
||||
id: `doughcon_critical_${doughcon}`,
|
||||
title: `${config.name}高风险预警`,
|
||||
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
|
||||
level: 'critical'
|
||||
})
|
||||
} else if (doughcon <= warningMax) {
|
||||
alerts.push({
|
||||
id: `doughcon_warning_${doughcon}`,
|
||||
title: `${config.name}风险抬升预警`,
|
||||
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
|
||||
level: 'warning'
|
||||
})
|
||||
}
|
||||
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
|
||||
alerts.push({
|
||||
id: `jump_${Math.floor(delta)}`,
|
||||
title: `${config.name}快速升温预警`,
|
||||
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
|
||||
level: 'critical'
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
run: async (context, config) => {
|
||||
const { axios, db, logJSON } = context
|
||||
const stableUrl = config.stable_url || 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS'
|
||||
const pizzintUrl = config.pizzint_url || 'https://www.pizzint.watch/'
|
||||
const stateKey = `skill_state:${String(config.symbol || 'us_war_pizzint')}:war_index`
|
||||
const failCountKey = `${stateKey}:fail_count`
|
||||
const failAlertThreshold = Number.isFinite(Number(config && config.fail_alert_consecutive))
|
||||
? Math.max(1, Number(config.fail_alert_consecutive))
|
||||
: 3
|
||||
const getState = db.prepare(`SELECT value FROM market_state WHERE key = ?`)
|
||||
const upsertState = 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 stateRow = getState.get(stateKey)
|
||||
const failCountRow = getState.get(failCountKey)
|
||||
const prevIndex = stateRow && Number.isFinite(Number(stateRow.value)) ? Number(stateRow.value) : NaN
|
||||
const failCount = failCountRow && Number.isFinite(Number(failCountRow.value)) ? Math.max(0, Number(failCountRow.value)) : 0
|
||||
const fallbackPrice = Number.isFinite(prevIndex) ? prevIndex : 20
|
||||
const buildFallback = (status, message) => {
|
||||
const nextFailCount = failCount + 1
|
||||
upsertState.run({ key: failCountKey, value: String(nextFailCount) })
|
||||
const explain = `指数说明:0-100 越高风险越高;当前为兜底值 ${fallbackPrice.toFixed(2)}。稳定源与备用源连续失败时会回退。`
|
||||
const data = [{
|
||||
label: config.name,
|
||||
symbol: config.symbol,
|
||||
source: 'war_index_fallback',
|
||||
name: `DOUGHCON N/A · ${status}`,
|
||||
price: fallbackPrice,
|
||||
prevClose: fallbackPrice,
|
||||
change: 0,
|
||||
change_percent: 0,
|
||||
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
market_status: config.market_status,
|
||||
pizzint_doughcon: null,
|
||||
pizzint_status: status,
|
||||
pizzint_locations: null,
|
||||
pizzint_feed_failed: 1,
|
||||
explain
|
||||
}]
|
||||
const alerts = nextFailCount >= failAlertThreshold
|
||||
? [{
|
||||
id: status.toLowerCase(),
|
||||
title: `${config.name}数据异常`,
|
||||
message: `${message}(连续失败 ${nextFailCount} 次)。${explain}`,
|
||||
level: 'warning'
|
||||
}]
|
||||
: []
|
||||
return { data, alerts }
|
||||
}
|
||||
|
||||
const stableRaw = await fetchFredVix(axios, stableUrl)
|
||||
const stableSnapshot = stableRaw ? parseFredVix(stableRaw) : null
|
||||
if (stableSnapshot) {
|
||||
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
|
||||
const warIndex = stableSnapshot.warIndex
|
||||
const prevClose = Number.isFinite(prevIndex) ? prevIndex : stableSnapshot.prevWarIndex
|
||||
const change = warIndex - prevClose
|
||||
const changePercent = prevClose ? (change / prevClose) * 100 : 0
|
||||
const explain = buildExplainText(stableSnapshot, 'FRED VIX')
|
||||
upsertState.run({ key: stateKey, value: String(warIndex) })
|
||||
const data = [{
|
||||
label: config.name,
|
||||
symbol: config.symbol,
|
||||
source: 'fred_vix',
|
||||
name: `VIX ${stableSnapshot.vix.toFixed(2)} · ${stableSnapshot.status}`,
|
||||
price: warIndex,
|
||||
prevClose,
|
||||
change,
|
||||
change_percent: changePercent,
|
||||
captured_at: stableSnapshot.capturedAt,
|
||||
market_status: config.market_status,
|
||||
pizzint_doughcon: null,
|
||||
pizzint_status: null,
|
||||
pizzint_locations: null,
|
||||
pizzint_feed_failed: 0,
|
||||
explain
|
||||
}]
|
||||
const alerts = buildAlerts(stableSnapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
|
||||
return { data, alerts }
|
||||
}
|
||||
|
||||
const candidates = buildCandidateUrls(pizzintUrl)
|
||||
let snapshot = null
|
||||
let fetchedCount = 0
|
||||
let parseFailedCount = 0
|
||||
let resolvedFrom = ''
|
||||
for (const candidate of candidates) {
|
||||
const html = await fetchPizzint(axios, candidate)
|
||||
if (!html) continue
|
||||
fetchedCount += 1
|
||||
const parsed = parsePizzint(html)
|
||||
if (parsed) {
|
||||
snapshot = parsed
|
||||
resolvedFrom = candidate
|
||||
break
|
||||
}
|
||||
parseFailedCount += 1
|
||||
}
|
||||
|
||||
if (!snapshot && fetchedCount === 0) {
|
||||
logJSON('skill.fetch.error', { skill: config.name, message: 'All sources fetch failed', candidates: candidates.length, stableUrl }, 'market_skills')
|
||||
return buildFallback('FETCH_FAILED', '稳定源 FRED VIX 与备用源 PizzINT 均抓取失败,已回退到兜底数值')
|
||||
}
|
||||
if (!snapshot) {
|
||||
logJSON('skill.parse.error', { skill: config.name, message: 'PizzINT parse failed after stable source failure', fetchedCount, parseFailedCount, stableUrl }, 'market_skills')
|
||||
return buildFallback('PARSE_FAILED', '稳定源不可用且备用源解析失败,已回退到兜底数值')
|
||||
}
|
||||
if (resolvedFrom && String(resolvedFrom) !== String(pizzintUrl)) {
|
||||
logJSON('skill.fetch.fallback', { skill: config.name, resolvedFrom }, 'market_skills')
|
||||
}
|
||||
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
|
||||
const warIndex = snapshot.warIndex
|
||||
const prevClose = Number.isFinite(prevIndex) ? prevIndex : warIndex
|
||||
const change = warIndex - prevClose
|
||||
const changePercent = prevClose ? (change / prevClose) * 100 : 0
|
||||
const explain = buildExplainText(snapshot, 'PizzINT')
|
||||
|
||||
upsertState.run({ key: stateKey, value: String(warIndex) })
|
||||
|
||||
const data = [{
|
||||
label: config.name,
|
||||
symbol: config.symbol,
|
||||
source: 'pizzint',
|
||||
name: `DOUGHCON ${snapshot.doughcon} · ${snapshot.status}`,
|
||||
price: warIndex,
|
||||
prevClose,
|
||||
change,
|
||||
change_percent: changePercent,
|
||||
captured_at: snapshot.capturedAt,
|
||||
market_status: config.market_status,
|
||||
pizzint_doughcon: snapshot.doughcon,
|
||||
pizzint_status: snapshot.status,
|
||||
pizzint_locations: snapshot.locations,
|
||||
pizzint_feed_failed: snapshot.feedFailed ? 1 : 0,
|
||||
explain
|
||||
}]
|
||||
|
||||
const alerts = buildAlerts(snapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
|
||||
return { data, alerts }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name_comment": "�曄內���蝘?,
|
||||
"name": "銝剖𤙴�嗉�-299瘜?,
|
||||
"card_order": 2,
|
||||
"card_type": "299",
|
||||
"card_span_comment": "�∠�璅芸��牐��埈㺭嚗?銝粹�霈歹�2銝箄楊銝文�",
|
||||
"card_span": 2,
|
||||
"card_custom_class_comment": "銝剖𤙴�嗉��∠�銝枏��瑕�蝐餃�嚗䔶��其��滨垢�祉�撅閧內",
|
||||
"card_custom_class": "card-zhonghang-299",
|
||||
"enabled_comment": "�臬炏�舐鍂霂亦��?(true/false)",
|
||||
"enabled": true,
|
||||
"display_comment": "�臬炏�曄內霂亙㨃�?(false �嗘��匧��唳旿銋煺�撠��)",
|
||||
"display": true,
|
||||
"schedule_comment": "�瑟鰵憸𤑳� (憒?10m 銵函內10���)",
|
||||
"schedule": "10m",
|
||||
"symbol_comment": "�烐綉��誨�?(�曇悖韐Y��硋�隞𡝗�)",
|
||||
"symbol": "sh601988_299",
|
||||
"source_symbol_comment": "�其��匧�K蝥輻��笔�隞��",
|
||||
"source_symbol": "sh601988",
|
||||
"url_comment": "�孵稬�∠��喃�閫埝�摮𣂼㦛��歲頧祉��啣�",
|
||||
"url": "https://quote.eastmoney.com/sh601988.html",
|
||||
"source_comment": "�唳旿�交� (憒?tencent)",
|
||||
"source": "tencent",
|
||||
"box_window_comment": "�嗥�隞瑞拳雿梶����隞�𣈲�?0/30/40",
|
||||
"box_window": 30,
|
||||
"box_confirm_days_comment": "蝞曹�蝒�聦蝖株恕憭拇㺭",
|
||||
"box_confirm_days": 2,
|
||||
"box_volume_ma_window_comment": "蝒�聦�交�鈭日����蝒堒藁",
|
||||
"box_volume_ma_window": 5,
|
||||
"box_volume_expand_ratio_comment": "蝒�聦�交𦆮�誩�齿㺭���?,
|
||||
"box_volume_expand_ratio": 1.2,
|
||||
"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",
|
||||
\"key\": \"\"
|
||||
},
|
||||
{
|
||||
"url_comment": "�券��𦻖��𧑐��",
|
||||
"url": "http://localhost:{{PORT}}/api/calendar/events",
|
||||
"enabled_comment": "�臬炏�舐鍂",
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"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,289 @@
|
||||
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 calculateTrailingMA = (data, endExclusive, period) => {
|
||||
if (!Array.isArray(data) || period <= 0) return null
|
||||
if (!Number.isInteger(endExclusive) || endExclusive < period) return null
|
||||
const subset = data.slice(endExclusive - period, endExclusive)
|
||||
if (subset.length < period) return null
|
||||
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 => {
|
||||
const volume = parseFloat(item[5])
|
||||
return {
|
||||
date: item[0],
|
||||
close: parseFloat(item[2]),
|
||||
volume: Number.isFinite(volume) ? volume : 0
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const buildAlerts = ({
|
||||
latestClose,
|
||||
prevClose,
|
||||
ma10,
|
||||
ma20,
|
||||
ma60,
|
||||
ma90,
|
||||
ma250,
|
||||
prevMA20,
|
||||
prevMA60,
|
||||
prevMA90,
|
||||
prevMA250,
|
||||
isTrendGateOpen,
|
||||
boxBreakoutConfirmed,
|
||||
boxBreakdownConfirmed
|
||||
}) => {
|
||||
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 (boxBreakoutConfirmed) {
|
||||
addAlert('box_breakout_confirmed', '箱体上沿突破已确认', '中国银行30日收盘价箱体上沿突破已连续2日确认,且突破日放量')
|
||||
}
|
||||
if (boxBreakdownConfirmed) {
|
||||
addAlert('box_breakdown_confirmed', '箱体下沿跌破已确认', '中国银行30日收盘价箱体下沿跌破已连续2日确认,建议防守', 'error')
|
||||
}
|
||||
|
||||
if (isTrendGateOpen) {
|
||||
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 || 'sh601988'
|
||||
|
||||
const klineData = await fetchKLine(axios, sourceSymbol)
|
||||
if (!klineData || klineData.length < 250) {
|
||||
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
|
||||
return null
|
||||
}
|
||||
|
||||
const closes = klineData.map(d => d.close)
|
||||
const volumes = klineData.map(d => d.volume)
|
||||
const latestClose = closes[closes.length - 1]
|
||||
const prevClose = closes[closes.length - 2]
|
||||
const prevPrevClose = closes[closes.length - 3]
|
||||
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)
|
||||
|
||||
const boxWindowRaw = parseInt(String(config.box_window || 30), 10)
|
||||
const boxWindow = [20, 30, 40].includes(boxWindowRaw) ? boxWindowRaw : 30
|
||||
const confirmDaysRaw = parseInt(String(config.box_confirm_days || 2), 10)
|
||||
const confirmDays = confirmDaysRaw >= 2 ? 2 : 1
|
||||
const volumeMAWindowRaw = parseInt(String(config.box_volume_ma_window || 5), 10)
|
||||
const volumeMAWindow = volumeMAWindowRaw > 0 ? volumeMAWindowRaw : 5
|
||||
const volumeExpandRatioRaw = parseFloat(String(config.box_volume_expand_ratio || 1.2))
|
||||
const volumeExpandRatio = Number.isFinite(volumeExpandRatioRaw) && volumeExpandRatioRaw > 0 ? volumeExpandRatioRaw : 1.2
|
||||
|
||||
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
|
||||
logJSON('skill.zhonghang_299.error', { message: 'MA calculation failed' }, 'market_skills')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Number.isFinite(prevPrevClose) || closes.length < boxWindow + 2) {
|
||||
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data for box', length: closes.length, boxWindow }, '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 isMA250SlopeUp = ma250 >= prevMA250
|
||||
const isMA60SlopeUp = ma60 >= prevMA60
|
||||
const latestIndex = closes.length - 1
|
||||
const prevIndex = closes.length - 2
|
||||
const getBoxUpper = idx => Math.max(...closes.slice(idx - boxWindow, idx))
|
||||
const getBoxLower = idx => Math.min(...closes.slice(idx - boxWindow, idx))
|
||||
const upperPrev = getBoxUpper(prevIndex)
|
||||
const upperLatest = getBoxUpper(latestIndex)
|
||||
const lowerPrev = getBoxLower(prevIndex)
|
||||
const lowerLatest = getBoxLower(latestIndex)
|
||||
const prevVolume = volumes[prevIndex]
|
||||
const latestVolume = volumes[latestIndex]
|
||||
const prevVolumeMA = calculateTrailingMA(volumes, prevIndex, volumeMAWindow)
|
||||
const latestVolumeMA = calculateTrailingMA(volumes, latestIndex, volumeMAWindow)
|
||||
const breakoutDayPrev = prevPrevClose <= upperPrev && prevClose > upperPrev
|
||||
const breakdownDayPrev = prevPrevClose >= lowerPrev && prevClose < lowerPrev
|
||||
const breakoutVolumeOkPrev = Number.isFinite(prevVolumeMA) ? prevVolume > prevVolumeMA * volumeExpandRatio : false
|
||||
const breakoutDayLatest = prevClose <= upperLatest && latestClose > upperLatest
|
||||
const breakoutVolumeOkLatest = Number.isFinite(latestVolumeMA) ? latestVolume > latestVolumeMA * volumeExpandRatio : false
|
||||
const boxBreakoutConfirmed = confirmDays === 2
|
||||
? breakoutDayPrev && latestClose > upperLatest && breakoutVolumeOkPrev
|
||||
: breakoutDayLatest && breakoutVolumeOkLatest
|
||||
const boxBreakdownConfirmed = confirmDays === 2
|
||||
? breakdownDayPrev && latestClose < lowerLatest
|
||||
: prevClose >= lowerLatest && latestClose < lowerLatest
|
||||
const isTrendGateOpen = latestClose > ma250 && isMA250SlopeUp && isMA60SlopeUp && boxBreakoutConfirmed
|
||||
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 = isTrendGateOpen ? (crossDownMA20 ? '已触发(加仓20%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
|
||||
const statusDownMA60 = isTrendGateOpen ? (crossDownMA60 ? '已触发(加仓40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
|
||||
const statusDownMA90 = isTrendGateOpen ? (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
|
||||
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
|
||||
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
|
||||
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
|
||||
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
|
||||
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
|
||||
const statusBoxBreakout = boxBreakoutConfirmed ? '已确认(连续2日 + 突破日放量)' : '未确认'
|
||||
const statusBoxBreakdown = boxBreakdownConfirmed ? '已确认(连续2日)' : '未确认'
|
||||
const statusBoxRange = `上沿${upperLatest.toFixed(2)} / 下沿${lowerLatest.toFixed(2)} (N=${boxWindow})`
|
||||
const breakoutDate = confirmDays === 2
|
||||
? (breakoutDayPrev ? klineData[prevIndex].date : '—')
|
||||
: (breakoutDayLatest ? klineData[latestIndex].date : '—')
|
||||
const breakoutConfirmDate = boxBreakoutConfirmed ? klineData[latestIndex].date : '—'
|
||||
const breakoutVolumeRatio = confirmDays === 2
|
||||
? (Number.isFinite(prevVolumeMA) && prevVolumeMA > 0 ? prevVolume / prevVolumeMA : null)
|
||||
: (Number.isFinite(latestVolumeMA) && latestVolumeMA > 0 ? latestVolume / latestVolumeMA : null)
|
||||
const breakoutVolumeRatioText = Number.isFinite(breakoutVolumeRatio) ? `${breakoutVolumeRatio.toFixed(2)}x` : '—'
|
||||
const breakoutVolumeGateText = `${volumeExpandRatio.toFixed(2)}x`
|
||||
|
||||
const htmlPayload = `
|
||||
<div class="zh299-wrap">
|
||||
<div class="zh299-price">价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)</div>
|
||||
<div class="zh299-grid">
|
||||
<div class="zh299-block">
|
||||
<div class="zh299-title">均线快照</div>
|
||||
<div class="zh299-row">MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}</div>
|
||||
<div class="zh299-row">MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}</div>
|
||||
<div class="zh299-row">趋势:${trendText} | 年线状态:${yearLineText}</div>
|
||||
<div class="zh299-row">箱体:${statusBoxRange}</div>
|
||||
</div>
|
||||
<div class="zh299-block">
|
||||
<div class="zh299-title">299执行面板</div>
|
||||
<div class="zh299-row">20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}</div>
|
||||
<div class="zh299-row">60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}</div>
|
||||
<div class="zh299-row">90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}</div>
|
||||
<div class="zh299-row">MA10>MA20:${statusTrend} | MA250:${statusMA250}</div>
|
||||
<div class="zh299-row">箱体上沿突破:${statusBoxBreakout}</div>
|
||||
<div class="zh299-row">突破日:${breakoutDate} | 确认日:${breakoutConfirmDate}</div>
|
||||
<div class="zh299-row">突破日量比:${breakoutVolumeRatioText} | 放量门槛:${breakoutVolumeGateText}</div>
|
||||
<div class="zh299-row">箱体下沿跌破:${statusBoxBreakdown}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const data = [{
|
||||
label: config.name,
|
||||
symbol: symbol,
|
||||
displaySymbol: sourceSymbol,
|
||||
isMaCard: true,
|
||||
render_type: 'zhonghang_299_html',
|
||||
html: htmlPayload,
|
||||
card_custom_class: config.card_custom_class || 'card-zhonghang-299',
|
||||
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,
|
||||
isTrendGateOpen,
|
||||
boxBreakoutConfirmed,
|
||||
boxBreakdownConfirmed
|
||||
})
|
||||
|
||||
return { data, alerts }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* market_tts.js - 独立 TTS 模块(阿里云 NLS 语音合成)
|
||||
* 不依赖 calendar_voice.js,markets 工具可独立部署
|
||||
*/
|
||||
const axios = require('axios')
|
||||
const Core = require('@alicloud/pop-core')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// 阿里云 NLS 配置(从环境变量读取,见 ~/Toolbox_local_creds.env.local)
|
||||
const ALIYUN_CONFIG = {
|
||||
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
|
||||
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
|
||||
appKey: process.env.ALIYUN_APPKEY || '',
|
||||
endpoint: 'https://nls-meta.cn-shanghai.aliyuncs.com',
|
||||
apiVersion: '2019-02-28'
|
||||
}
|
||||
|
||||
// 可用中文声音
|
||||
const VOICES = {
|
||||
xiaoyun: 'Xiaoyun', // 标准女声
|
||||
xiaogang: 'Xiaogang', // 标准男声
|
||||
zhixuan: 'Zhixuan', // 自然女声(推荐)
|
||||
zhiying: 'Zhiying' // 自然男声
|
||||
}
|
||||
|
||||
// 每段最大字符数(阿里云 TTS 建议不超过 500)
|
||||
const MAX_SEGMENT_CHARS = 450
|
||||
|
||||
// 缓存 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 result = await client.request('CreateToken', {}, { method: 'POST', formatParams: false })
|
||||
if (result.Token && result.Token.Id) {
|
||||
cachedToken = result.Token.Id
|
||||
tokenExpire = result.Token.ExpireTime
|
||||
return cachedToken
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MarketTTS] Get token failed:', e.message)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字转语音,返回 WAV 音频 Buffer
|
||||
* @param {string} text - 要朗读的文字
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.voice] - 声音名称
|
||||
* @param {number} [options.sample_rate] - 采样率
|
||||
* @param {number} [options.volume] - 音量 0~100
|
||||
* @param {number} [options.speech_rate] - 语速 -500~500
|
||||
* @param {number} [options.pitch_rate] - 音调 -500~500
|
||||
* @returns {Promise<Buffer>} WAV 音频数据
|
||||
*/
|
||||
const synthesize = async (text, options = {}) => {
|
||||
if (!text || !text.trim()) throw new Error('Text is empty')
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) throw new Error('Failed to get NLS token')
|
||||
|
||||
const voice = options.voice || 'Zhixuan'
|
||||
const sampleRate = options.sample_rate || 16000
|
||||
const volume = options.volume ?? 50
|
||||
const speechRate = options.speech_rate ?? 0
|
||||
const pitchRate = options.pitch_rate ?? 0
|
||||
|
||||
const url = `https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts?appkey=${ALIYUN_CONFIG.appKey}`
|
||||
|
||||
const payload = {
|
||||
text: text.trim(),
|
||||
format: 'wav',
|
||||
sample_rate: sampleRate,
|
||||
voice,
|
||||
volume,
|
||||
speech_rate: speechRate,
|
||||
pitch_rate: pitchRate
|
||||
}
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
'X-NLS-Token': token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
const ct = String(response.headers['content-type'] || '').toLowerCase()
|
||||
if (ct.includes('audio/')) {
|
||||
return Buffer.from(response.data)
|
||||
}
|
||||
|
||||
try {
|
||||
const errBody = JSON.parse(Buffer.from(response.data).toString('utf-8'))
|
||||
throw new Error(errBody.message || `TTS failed: ${response.status}`)
|
||||
} catch (parseErr) {
|
||||
if (parseErr.message && !parseErr.message.includes('parse')) throw parseErr
|
||||
throw new Error(`TTS failed: HTTP ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本按标点符号分成适合 TTS 的段
|
||||
*/
|
||||
const splitText = (text, maxLen = MAX_SEGMENT_CHARS) => {
|
||||
const segments = []
|
||||
let remaining = text.trim()
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLen) {
|
||||
segments.push(remaining)
|
||||
break
|
||||
}
|
||||
// 从 maxLen 位置往回找最近的句号/问号/感叹号/换行
|
||||
let cut = remaining.lastIndexOf('。', maxLen)
|
||||
if (cut <= 0) cut = remaining.lastIndexOf('?', maxLen)
|
||||
if (cut <= 0) cut = remaining.lastIndexOf('!', maxLen)
|
||||
if (cut <= 0) cut = remaining.lastIndexOf('\n', maxLen)
|
||||
if (cut <= 0) cut = remaining.lastIndexOf(',', maxLen)
|
||||
if (cut <= 0) cut = remaining.lastIndexOf(';', maxLen)
|
||||
if (cut <= 0) cut = maxLen // 找不到就硬切
|
||||
segments.push(remaining.slice(0, cut + 1).trim())
|
||||
remaining = remaining.slice(cut + 1).trim()
|
||||
}
|
||||
return segments.filter(s => s.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 WAV Buffer 为一个(标准 16-bit PCM,同格式)
|
||||
*/
|
||||
const mergeWavBuffers = (buffers) => {
|
||||
if (buffers.length === 0) throw new Error('No audio buffers to merge')
|
||||
if (buffers.length === 1) return buffers[0]
|
||||
|
||||
// 标准 WAV PCM 头 44 字节
|
||||
const HEADER_SIZE = 44
|
||||
const chunks = buffers.map(buf => buf.slice(HEADER_SIZE))
|
||||
const totalDataSize = chunks.reduce((sum, c) => sum + c.length, 0)
|
||||
|
||||
// 用第一个段的头为基础
|
||||
const header = Buffer.from(buffers[0])
|
||||
|
||||
// RIFF 文件大小(字节 4-7)= 总大小 - 8
|
||||
const fileSize = HEADER_SIZE + totalDataSize
|
||||
header.writeUInt32LE(fileSize - 8, 4)
|
||||
|
||||
// data chunk 大小(字节 40-43)
|
||||
header.writeUInt32LE(totalDataSize, 40)
|
||||
|
||||
return Buffer.concat([header, ...chunks])
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成整章语音并保存到文件
|
||||
* @param {string} text - 整章文本
|
||||
* @param {string} voice - 声音名称
|
||||
* @param {function} onLog - 日志回调 (msg, type) => void
|
||||
* @returns {Promise<{filePath: string, duration: number}>} 保存路径和时长
|
||||
*/
|
||||
const generateChapter = async (text, voice = 'Zhixuan', onLog = () => {}) => {
|
||||
const log = (msg, type = 'info') => {
|
||||
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
const line = `[${ts}] ${msg}`
|
||||
console.log('[MarketTTS]', line)
|
||||
onLog(line, type)
|
||||
}
|
||||
|
||||
// 1. 分段
|
||||
const segments = splitText(text)
|
||||
log(`文本已分段:共 ${segments.length} 段,总 ${text.length} 字`)
|
||||
|
||||
// 2. 获取 Token
|
||||
log('正在获取阿里云 TTS Token...')
|
||||
const token = await getToken()
|
||||
if (!token) { log('获取 Token 失败!请检查阿里云配置', 'error'); throw new Error('Token failed') }
|
||||
log('Token 获取成功')
|
||||
|
||||
// 3. 逐段合成
|
||||
const buffers = []
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segText = segments[i]
|
||||
log(`正在合成第 ${i + 1}/${segments.length} 段(${segText.length} 字)...`)
|
||||
try {
|
||||
const buf = await synthesize(segText, { voice })
|
||||
buffers.push(buf)
|
||||
const secs = ((buf.length - 44) / 16000 / 2).toFixed(1) // 16-bit mono 16kHz
|
||||
log(`第 ${i + 1}/${segments.length} 段合成成功(约 ${secs}s)`)
|
||||
} catch (e) {
|
||||
log(`第 ${i + 1}/${segments.length} 段合成失败:${e.message}`, 'error')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 合并
|
||||
log('正在合并音频片段...')
|
||||
const merged = mergeWavBuffers(buffers)
|
||||
const totalSecs = ((merged.length - 44) / 16000 / 2).toFixed(1)
|
||||
log(`合并完成:总时长约 ${totalSecs}s,文件大小 ${(merged.length / 1024 / 1024).toFixed(1)}MB`)
|
||||
|
||||
return { buffer: merged, duration: parseFloat(totalSecs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 WAV 文件到公共目录
|
||||
* @param {Buffer} buffer - WAV 音频数据
|
||||
* @param {string} fileName - 文件名(如 ch_00.wav)
|
||||
* @returns {string} 保存路径
|
||||
*/
|
||||
const saveAudioFile = (buffer, fileName) => {
|
||||
const audioDir = path.join(process.cwd(), 'public', 'tools', 'markets', 'audio')
|
||||
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
|
||||
const filePath = path.join(audioDir, fileName)
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
return '/tools/markets/audio/' + fileName
|
||||
}
|
||||
|
||||
module.exports = { synthesize, VOICES, generateChapter, saveAudioFile, splitText }
|
||||
@@ -0,0 +1,854 @@
|
||||
const { db, upsertMarketArchive, getMarketArchive } = require('./db')
|
||||
const { fetchSina, fetchYahoo, fetchTencent, fetchEastmoneyComexGold } = require('./marketsClient')
|
||||
const { logJSON } = require('./logger')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const nodemailer = require('nodemailer')
|
||||
const axios = require('axios')
|
||||
const { getLatestData } = require('./market_skills/manager')
|
||||
const securityCalendar = require('./security_calendar')
|
||||
|
||||
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 cfgPath = path.join(process.cwd(), 'config', 'markets.json')
|
||||
if (!fs.existsSync(cfgPath)) {
|
||||
const defaultPort = process.env.PORT || 5050
|
||||
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
|
||||
const defaultCalendar = `http://localhost:${defaultPort}/api/calendar/public`
|
||||
fs.writeFileSync(cfgPath, JSON.stringify({
|
||||
ui_mode_comment: '界面模式(debug=显示完整交互弹窗,normal=成功操作更快自动关闭)',
|
||||
ui_mode: 'debug',
|
||||
skills_only_mode: true,
|
||||
yahoo_probe_comment: 'Yahoo 探针配置(海外行情与历史日线通过海外探针中转)',
|
||||
yahoo_probe: {
|
||||
enabled_comment: '是否启用 Yahoo 探针',
|
||||
enabled: true,
|
||||
base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)',
|
||||
base_url: 'http://47.252.30.127:19071',
|
||||
token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置',
|
||||
token: '',
|
||||
endpoint_comment: 'Yahoo 探针统一路由',
|
||||
endpoint: '/probe/yahoo',
|
||||
quote_endpoint_comment: '实时报价路由,默认与 endpoint 一致',
|
||||
quote_endpoint: '/probe/yahoo',
|
||||
chart_endpoint_comment: '历史K线路由,默认与 endpoint 一致',
|
||||
chart_endpoint: '/probe/yahoo',
|
||||
request_timeout_ms_comment: '请求超时毫秒数',
|
||||
request_timeout_ms: 15000,
|
||||
quote_ttl_seconds_comment: '实时报价缓存秒数,降低 Yahoo 访问频率',
|
||||
quote_ttl_seconds: 120,
|
||||
chart_ttl_seconds_comment: '历史K线缓存秒数,降低 Yahoo 访问频率',
|
||||
chart_ttl_seconds: 21600,
|
||||
quote_batch_size_comment: '单次报价批量 symbol 数,建议 3~5',
|
||||
quote_batch_size: 4,
|
||||
quote_min_interval_ms_comment: '报价请求最小间隔毫秒,避免探针触发限频',
|
||||
quote_min_interval_ms: 2500,
|
||||
chart_min_interval_ms_comment: 'K线请求最小间隔毫秒,避免探针触发限频',
|
||||
chart_min_interval_ms: 2500
|
||||
},
|
||||
watchlist: [],
|
||||
threshold_percent: -2,
|
||||
comex_gold_min_price: 4800,
|
||||
comex_gold_max_price: 5500,
|
||||
email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } },
|
||||
weekly_api_base: defaultBase,
|
||||
weekly_key: "",
|
||||
weekly_sync_enabled: true,
|
||||
calendar_api_base: defaultCalendar,
|
||||
calendar_sync_enabled: true
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS market_quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT,
|
||||
symbol TEXT,
|
||||
source TEXT,
|
||||
name TEXT,
|
||||
price REAL,
|
||||
prev_close REAL,
|
||||
change REAL,
|
||||
change_percent REAL,
|
||||
captured_at TEXT DEFAULT (datetime('now'))
|
||||
);`
|
||||
)
|
||||
|
||||
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 queryByDate = db.prepare(
|
||||
`SELECT * FROM market_quotes WHERE date(captured_at)=date(?) ORDER BY label`
|
||||
)
|
||||
|
||||
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 getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||||
const SKILLS_ONLY_WATCHLIST_ERROR = 'markets 配置错误:skills_only_mode=true 时禁止使用 watchlist,请在 src/server/market_skills 下新增或修改 skill'
|
||||
const assertMarketsConfigInvariant = cfg => {
|
||||
const skillsOnlyMode = cfg && cfg.skills_only_mode !== false
|
||||
const watchlist = cfg && Array.isArray(cfg.watchlist) ? cfg.watchlist : []
|
||||
if (skillsOnlyMode && watchlist.length > 0) throw new Error(SKILLS_ONLY_WATCHLIST_ERROR)
|
||||
}
|
||||
const setConfig = cfg => {
|
||||
assertMarketsConfigInvariant(cfg)
|
||||
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
|
||||
}
|
||||
|
||||
const normCode = s => {
|
||||
const x = String(s).toLowerCase()
|
||||
if (x === '000001' || x === 'sh000001') return 'sh000001'
|
||||
if (x === '399001' || x === 'sz399001') return 'sz399001'
|
||||
if (x === '399006' || x === 'sz399006') return 'sz399006'
|
||||
if (x === '000300' || x === 'sh000300') return 'sh000300'
|
||||
if (x === '899050' || x.startsWith('bj899050')) return 'bj899050'
|
||||
if (x === '000688' || x === 'sh000688') return 'sh000688'
|
||||
if (x === '^ixic' || x === '.ixic' || x === 'ixic' || x === 'gb_ixic') return 'gb_ixic'
|
||||
if (x === '^gspc' || x === '.inx' || x === 'inx' || x === 'gb_inx') return 'gb_inx'
|
||||
return s
|
||||
}
|
||||
|
||||
const normalizeWatchlist = list => (list || []).map(w => ({
|
||||
label: w.label || w.symbol,
|
||||
symbol: normCode(w.symbol || w.code || ''),
|
||||
source: w.source || 'sina',
|
||||
url: w.url,
|
||||
displaySymbol: w.displaySymbol,
|
||||
card_type: w.card_type,
|
||||
card_order: Number.isFinite(Number(w.card_order)) ? Number(w.card_order) : undefined,
|
||||
market_status: w.market_status
|
||||
}))
|
||||
|
||||
const resolveEastmoneyUrl = (symbol, url) => {
|
||||
if (url) return url
|
||||
const s = String(symbol || '')
|
||||
const lower = s.toLowerCase()
|
||||
if (lower.startsWith('zs')) return `https://quote.eastmoney.com/gb/${s}.html`
|
||||
if (lower.startsWith('us')) return `https://quote.eastmoney.com/us/${s.slice(2)}.html`
|
||||
if (lower.startsWith('hk')) return `https://quote.eastmoney.com/hk/${s}.html`
|
||||
return `https://quote.eastmoney.com/${s}.html`
|
||||
}
|
||||
|
||||
const fetchEastmoneyQuote = async (url, symbol, nameFallback) => {
|
||||
const secidMap = {
|
||||
zsn225: '100.N225',
|
||||
zsgdaxi: '100.GDAXI',
|
||||
usnfty: '105.NFTY',
|
||||
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 last_trade_date = String(last[0] || '').trim()
|
||||
const price = Number(last[2])
|
||||
const prevClose = prev ? Number(prev[2]) : NaN
|
||||
const pct = Number(last[7])
|
||||
if (!Number.isFinite(price) || price <= 0) return null
|
||||
const hasPrev = Number.isFinite(prevClose) && prevClose > 0
|
||||
const change = hasPrev ? price - prevClose : (Number.isFinite(pct) ? (price * pct) / (100 + pct) : 0)
|
||||
const change_percent = Number.isFinite(pct) ? pct : (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 : (change_percent ? price / (1 + change_percent / 100) : 0),
|
||||
change,
|
||||
change_percent,
|
||||
last_trade_date,
|
||||
captured_at,
|
||||
source: 'eastmoney'
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
const trace = []
|
||||
const pushTrace = (stage, detail = {}) => {
|
||||
trace.push({ time: new Date().toISOString(), stage, ...detail })
|
||||
}
|
||||
const cfgRaw = getConfig()
|
||||
assertMarketsConfigInvariant(cfgRaw)
|
||||
const skillsOnlyMode = cfgRaw.skills_only_mode !== false
|
||||
const rawWatchlist = Array.isArray(cfgRaw.watchlist) ? cfgRaw.watchlist : []
|
||||
const watchlist = skillsOnlyMode ? [] : normalizeWatchlist(rawWatchlist)
|
||||
pushTrace('markets.fetch.begin', { skills_only_mode: skillsOnlyMode, watchlist_count: watchlist.length })
|
||||
const eastmoneyItems = watchlist.filter(w => String(w.source || '').toLowerCase() === 'eastmoney')
|
||||
const tencentMap = {
|
||||
'sh000001': 'sh000001',
|
||||
'sz399001': 'sz399001',
|
||||
'sz399006': 'sz399006',
|
||||
'sh000300': 'sh000300',
|
||||
'sh000688': 'sh000688',
|
||||
'bj899050': 'bj899050',
|
||||
'rt_hkHSI': 'hkHSI',
|
||||
'rt_hk03153': 'hk03153',
|
||||
'gb_ixic': 'usIXIC',
|
||||
'gb_inx': 'usINX',
|
||||
'gb_inda': 'usINDA',
|
||||
'gb_ewg': 'usEWG',
|
||||
'hf_GC': 'hf_GC',
|
||||
}
|
||||
let tencent = []
|
||||
const symbolsTencent = watchlist.filter(w => tencentMap[w.symbol]).map(w => tencentMap[w.symbol])
|
||||
if (symbolsTencent.length > 0) {
|
||||
try {
|
||||
const tq = await fetchTencent(symbolsTencent)
|
||||
const rev = Object.entries(tencentMap).reduce((acc,[k,v]) => { acc[v]=k; return acc }, {})
|
||||
tencent = tq.map(q => ({ symbol: rev[q.symbol] || q.symbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: 'tencent' }))
|
||||
logJSON('markets.source.status', { tencent: tencent.length }, 'markets')
|
||||
pushTrace('markets.fetch.tencent.ok', { requested: symbolsTencent.length, count: tencent.length })
|
||||
} catch (e) { logJSON('markets.source.error', { source: 'tencent', error: String(e.message || e) }, 'markets') }
|
||||
}
|
||||
const sinaSymbols = watchlist.map(w => w.symbol)
|
||||
let sina = []
|
||||
try { sina = await fetchSina(sinaSymbols) } catch (e) { logJSON('markets.source.error', { source: 'sina', error: String(e.message || e) }, 'markets') }
|
||||
logJSON('markets.source.status', { sina: sina.length }, 'markets')
|
||||
pushTrace('markets.fetch.sina.done', { requested: sinaSymbols.length, count: sina.length })
|
||||
// Fallback for US indices/ETF via Yahoo when Sina blocked
|
||||
const yahooMap = {
|
||||
'gb_inda': 'INDA',
|
||||
'gb_ewg': 'EWG'
|
||||
}
|
||||
const needYahoo = watchlist
|
||||
.filter(w => yahooMap[w.symbol])
|
||||
.filter(w => !sina.find(r => r.symbol === w.symbol) && !tencent.find(r => r.symbol === w.symbol))
|
||||
let yahoo = []
|
||||
if (needYahoo.length > 0) {
|
||||
try {
|
||||
const symbolsYahoo = needYahoo.map(w => yahooMap[w.symbol])
|
||||
const yq = await fetchYahoo(symbolsYahoo, { trace })
|
||||
yahoo = yq.map(q => {
|
||||
// reverse-map to our symbol keys
|
||||
const pair = Object.entries(yahooMap).find(([k, v]) => v === q.symbol)
|
||||
const ourSymbol = pair ? pair[0] : q.symbol
|
||||
return { symbol: ourSymbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: q.source || 'yahoo_probe' }
|
||||
})
|
||||
logJSON('markets.source.status', { yahoo: yahoo.length }, 'markets')
|
||||
pushTrace('markets.fetch.yahoo.done', { requested: symbolsYahoo.length, count: yahoo.length })
|
||||
} catch (e) {
|
||||
pushTrace('markets.fetch.yahoo.error', { error: String(e.message || e) })
|
||||
logJSON('markets.source.error', { source: 'yahoo', error: String(e.message || e) }, 'markets')
|
||||
}
|
||||
}
|
||||
let eastmoney = []
|
||||
if (eastmoneyItems.length > 0) {
|
||||
for (const w of eastmoneyItems) {
|
||||
const url = resolveEastmoneyUrl(w.symbol, w.url)
|
||||
const item = await fetchEastmoneyQuote(url, w.symbol, w.label || w.symbol)
|
||||
if (item) eastmoney.push(item)
|
||||
}
|
||||
}
|
||||
const merged = []
|
||||
for (const w of watchlist) {
|
||||
let q = null
|
||||
if (String(w.source || '').toLowerCase() === 'eastmoney') {
|
||||
q = eastmoney.find(r => r.symbol === w.symbol)
|
||||
}
|
||||
if (!q) q = tencent.find(r => r.symbol === w.symbol)
|
||||
if (!q) q = sina.find(r => r.symbol === w.symbol)
|
||||
if (!q) q = yahoo.find(r => r.symbol === w.symbol)
|
||||
if (w.symbol === 'hf_GC' && (!q || !Number.isFinite(q.price) || q.price <= 0)) {
|
||||
try {
|
||||
const em = await fetchEastmoneyComexGold()
|
||||
if (em) q = em
|
||||
} catch (e) {
|
||||
logJSON('markets.source.error', { source: 'eastmoney', error: String(e.message || e) }, 'markets')
|
||||
}
|
||||
}
|
||||
if (q) merged.push({
|
||||
label: w.label,
|
||||
...q,
|
||||
url: w.url || resolveEastmoneyUrl(w.symbol, w.url),
|
||||
displaySymbol: w.displaySymbol || w.symbol,
|
||||
card_type: w.card_type,
|
||||
card_order: w.card_order,
|
||||
market_status: w.market_status || q.market_status
|
||||
})
|
||||
}
|
||||
for (const m of merged) insertQuote.run(m)
|
||||
|
||||
// Merge with Skill Manager data
|
||||
const skillData = getLatestData()
|
||||
const skillMap = new Map()
|
||||
for (const s of skillData) {
|
||||
if (s && s.symbol) skillMap.set(String(s.symbol).toLowerCase(), s)
|
||||
}
|
||||
|
||||
const final = []
|
||||
const seen = new Set()
|
||||
|
||||
// Process global items (replace with skill data if available)
|
||||
for (const m of merged) {
|
||||
const k = String(m.symbol).toLowerCase()
|
||||
if (skillMap.has(k)) {
|
||||
final.push(skillMap.get(k))
|
||||
} else {
|
||||
final.push(m)
|
||||
}
|
||||
seen.add(k)
|
||||
}
|
||||
|
||||
// Append remaining skill items
|
||||
for (const [k, s] of skillMap) {
|
||||
if (!seen.has(k)) {
|
||||
final.push(s)
|
||||
seen.add(k)
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCounts = final.reduce((acc, item) => {
|
||||
const key = String(item && item.source || 'unknown')
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
// #region debug-point E:fetchall-final
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = final.filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'E', location: 'markets.js:fetchAll:final', msg: '[DEBUG] markets fetchAll final summary', data: { merged_count: merged.length, final_count: final.length, skill_count: skillData.length, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol), source_counts: sourceCounts }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
pushTrace('markets.fetch.end', {
|
||||
merged_count: merged.length,
|
||||
final_count: final.length,
|
||||
source_counts: sourceCounts,
|
||||
skill_count: skillData.length
|
||||
})
|
||||
|
||||
return {
|
||||
quotes: final,
|
||||
trace: {
|
||||
events: trace,
|
||||
summary: {
|
||||
skills_only_mode: skillsOnlyMode,
|
||||
watchlist_count: watchlist.length,
|
||||
skill_count: skillData.length,
|
||||
merged_count: merged.length,
|
||||
final_count: final.length,
|
||||
source_counts: sourceCounts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 getCalendarCfg = () => {
|
||||
const cfg = getConfig()
|
||||
const port = process.env.PORT || 5050
|
||||
const base = cfg.calendar_api_base || `http://localhost:${port}/api/calendar/public`
|
||||
const enabled = cfg.calendar_sync_enabled !== false
|
||||
return { base, enabled }
|
||||
}
|
||||
|
||||
const normalizeTradeDate = value => {
|
||||
const text = String(value || '').trim()
|
||||
const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
const formatDateInTimeZone = (date, timeZone) => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(date)
|
||||
const pick = type => parts.find(item => item.type === type)?.value || '00'
|
||||
return `${pick('year')}-${pick('month')}-${pick('day')}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const addDaysToDateStr = (dateStr, offsetDays) => {
|
||||
const m = String(dateStr || '').match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!m) return ''
|
||||
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])))
|
||||
d.setUTCDate(d.getUTCDate() + offsetDays)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
||||
}
|
||||
|
||||
const getMarketLocalYesterday = q => {
|
||||
const timezone = String((((q || {}).market_status) || {}).timezone || '').trim()
|
||||
if (!timezone) return ''
|
||||
const localToday = formatDateInTimeZone(new Date(), timezone)
|
||||
if (!localToday) return ''
|
||||
return addDaysToDateStr(localToday, -1)
|
||||
}
|
||||
|
||||
const isChinaSecurityQuote = q => {
|
||||
const sym = String((q && q.symbol) || '').toLowerCase()
|
||||
const timezone = String((((q || {}).market_status) || {}).timezone || '').trim()
|
||||
if (timezone !== 'Asia/Shanghai') return false
|
||||
return /^(sh|sz|bj)\d{6}$/.test(sym) || /^sz399\d{3}$/.test(sym)
|
||||
}
|
||||
|
||||
const getChinaCalendarDay = async (dateStr, calendarCache) => {
|
||||
const safeDate = normalizeTradeDate(dateStr)
|
||||
if (!safeDate) return null
|
||||
const monthKey = safeDate.slice(0, 7)
|
||||
if (!monthKey) return null
|
||||
if (!calendarCache.has(monthKey)) {
|
||||
try {
|
||||
const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false })
|
||||
calendarCache.set(monthKey, payload)
|
||||
} catch {
|
||||
calendarCache.set(monthKey, null)
|
||||
}
|
||||
}
|
||||
const payload = calendarCache.get(monthKey)
|
||||
const days = payload && Array.isArray(payload.days) ? payload.days : []
|
||||
return days.find(day => String(day && day.date || '') === safeDate) || null
|
||||
}
|
||||
|
||||
const shouldNotifyByLastTradeDate = async (q, calendarCache) => {
|
||||
const lastTradeDate = normalizeTradeDate(q && q.last_trade_date)
|
||||
if (!lastTradeDate) return true
|
||||
const marketYesterday = getMarketLocalYesterday(q)
|
||||
if (!marketYesterday) return true
|
||||
return lastTradeDate === marketYesterday
|
||||
}
|
||||
|
||||
const filterNotifyCandidates = async quotes => {
|
||||
const passed = []
|
||||
const skipped = []
|
||||
const calendarCache = new Map()
|
||||
for (const q of quotes || []) {
|
||||
const lastTradeDate = normalizeTradeDate(q && q.last_trade_date)
|
||||
const marketYesterday = getMarketLocalYesterday(q)
|
||||
let allow = true
|
||||
let skipReason = ''
|
||||
if (lastTradeDate) {
|
||||
allow = !marketYesterday || lastTradeDate === marketYesterday
|
||||
if (!allow) skipReason = 'last_trade_date_not_yesterday'
|
||||
} else if (isChinaSecurityQuote(q) && marketYesterday) {
|
||||
const calendarDay = await getChinaCalendarDay(marketYesterday, calendarCache)
|
||||
if (calendarDay && calendarDay.is_trading_day === false) {
|
||||
allow = false
|
||||
skipReason = 'china_security_closed_yesterday'
|
||||
}
|
||||
}
|
||||
if (allow) passed.push(q)
|
||||
else skipped.push({
|
||||
symbol: q && q.symbol,
|
||||
label: q && q.label,
|
||||
last_trade_date: lastTradeDate,
|
||||
market_yesterday: marketYesterday,
|
||||
reason: skipReason
|
||||
})
|
||||
}
|
||||
return { passed, skipped }
|
||||
}
|
||||
|
||||
const isComexGold = q => {
|
||||
if (!q) return false
|
||||
const sym = String(q.symbol || '').toLowerCase()
|
||||
const label = String(q.label || '')
|
||||
const name = String(q.name || '')
|
||||
if (sym === 'hf_gc') return true
|
||||
if (label.includes('COMEX黄金')) return true
|
||||
if (/comex/i.test(name) && /gold|黄金/.test(name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const maybeNotify = async quotes => {
|
||||
const cfg = getConfig()
|
||||
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2
|
||||
const goldMin = typeof cfg.comex_gold_min_price === 'number' ? cfg.comex_gold_min_price : 4800
|
||||
const goldMax = typeof cfg.comex_gold_max_price === 'number' ? cfg.comex_gold_max_price : 5500
|
||||
const badCommon = quotes.filter(q => typeof q.change_percent === 'number' && q.change_percent <= threshold)
|
||||
const badGoldPrice = quotes
|
||||
.filter(isComexGold)
|
||||
.filter(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax))
|
||||
const badMap = new Map()
|
||||
for (const q of badCommon) badMap.set(q.symbol, q)
|
||||
for (const q of badGoldPrice) badMap.set(q.symbol, q)
|
||||
const rawBad = Array.from(badMap.values())
|
||||
const { passed: bad, skipped } = await filterNotifyCandidates(rawBad)
|
||||
if (skipped.length > 0) {
|
||||
logJSON('markets.notify.skip.closed_yesterday', { count: skipped.length, items: skipped }, 'markets')
|
||||
}
|
||||
if (bad.length === 0) return false
|
||||
const subject = `市场下跌预警 ${bad.map(b => b.label).join(', ')}`
|
||||
const lineTs = fmtCNDateTimeSrv(new Date())
|
||||
const body = bad.map(b => `【${lineTs}】 ${b.label} ${b.symbol} src ${b.source} ${Number.isFinite(b.change_percent) ? b.change_percent.toFixed(2) : '0.00'}% price ${b.price}`).join('\n')
|
||||
if (cfg.email && cfg.email.enabled) {
|
||||
try {
|
||||
const t = nodemailer.createTransport(cfg.email.smtp)
|
||||
await t.sendMail({ from: cfg.email.from, to: cfg.email.to.join(','), subject, text: body })
|
||||
try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {}
|
||||
} catch (e) {
|
||||
const errMsg = String(e.message || e)
|
||||
try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {}
|
||||
logJSON('markets.email.send.error', { code: e && e.code, message: errMsg }, 'markets')
|
||||
}
|
||||
}
|
||||
try {
|
||||
const api = getWeeklyCfg()
|
||||
if (api.enabled) {
|
||||
const now = fmtCNParts()
|
||||
const date = now.dateStr
|
||||
const idKey = `${date}-${String(now.hour).padStart(2,'0')}`
|
||||
const payload = { key: api.key, source: 'markets', external_id: `markets-alert-${idKey}`, title: subject, content: body, status_code: 'new', status_label: '📅本周新增', date }
|
||||
const url = `${api.base}/ext/event`
|
||||
logJSON('markets.weekly.push.try', { url, date, count: bad.length }, 'markets')
|
||||
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
|
||||
logJSON('markets.weekly.push.resp', { status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'markets')
|
||||
}
|
||||
} catch (e) {
|
||||
const status = e && e.response ? e.response.status : null
|
||||
const data = e && e.response ? e.response.data : null
|
||||
logJSON('markets.weekly.push.error', { status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets')
|
||||
}
|
||||
try {
|
||||
const cal = getCalendarCfg()
|
||||
if (cal.enabled) {
|
||||
const now = fmtCNParts()
|
||||
const dateStr = now.dateStr
|
||||
const hhmm = '15:00'
|
||||
const tsLocal = `${dateStr} ${hhmm}`
|
||||
const calendarUrl = `${cal.base}/events`
|
||||
const ev = { title: subject, content: body, start: tsLocal, end: tsLocal, remindAt: tsLocal, allDay: 0 }
|
||||
logJSON('markets.calendar.push.try', { url: calendarUrl, start: tsLocal }, 'markets')
|
||||
const cresp = await axios.post(calendarUrl, ev, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
|
||||
logJSON('markets.calendar.push.resp', { status: cresp && cresp.status, ok: !!(cresp && cresp.data && cresp.data.ok) }, 'markets')
|
||||
}
|
||||
} catch (e) {
|
||||
const status = e && e.response ? e.response.status : null
|
||||
const data = e && e.response ? e.response.data : null
|
||||
logJSON('markets.calendar.push.error', { status, code: e && e.code, message: String(e.message || e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fmtCNDateTimeSrv = (d) => {
|
||||
const date = d instanceof Date ? d : new Date()
|
||||
try {
|
||||
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
|
||||
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
||||
} catch {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const y = date.getFullYear(), m = pad(date.getMonth()+1), day = pad(date.getDate()), hh = pad(date.getHours()), mm = pad(date.getMinutes()), ss = pad(date.getSeconds())
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
}
|
||||
|
||||
const fmtCNParts = () => {
|
||||
try {
|
||||
const d = new Date()
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false }).formatToParts(d)
|
||||
const get = t => parts.find(p => p.type === t)?.value || '00'
|
||||
const y = get('year'), m = get('month'), day = get('day'), hour = get('hour'), minute = get('minute')
|
||||
return { dateStr: `${y}-${m}-${day}`, hour: parseInt(hour,10), minute: parseInt(minute,10) }
|
||||
} catch {
|
||||
const d = new Date(); return { dateStr: d.toISOString().slice(0,10), hour: d.getHours(), minute: d.getMinutes() }
|
||||
}
|
||||
}
|
||||
|
||||
const getTZOffset = (timeZone) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const iso = now.toLocaleString('en-US', { timeZone, timeZoneName: 'longOffset' });
|
||||
const match = iso.match(/GMT([+-]\d{2}):?(\d{2})?/);
|
||||
if (!match) return 0;
|
||||
const sign = match[1][0] === '-' ? -1 : 1;
|
||||
const h = parseInt(match[1].substring(1));
|
||||
const m = match[2] ? parseInt(match[2]) : 0;
|
||||
return sign * (h + m / 60);
|
||||
} catch (e) { return 0; }
|
||||
};
|
||||
|
||||
const getMarketStatus = (config) => {
|
||||
if (!config || !config.timezone || !config.sessions) return null;
|
||||
|
||||
const now = new Date();
|
||||
const targetOffset = getTZOffset(config.timezone);
|
||||
const bjOffset = 8; // Beijing is UTC+8
|
||||
const diffHours = bjOffset - targetOffset;
|
||||
|
||||
// Check if Open (using target local time)
|
||||
const localStr = now.toLocaleString('en-US', { timeZone: config.timezone, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const [h, m] = localStr.split(':').map(Number);
|
||||
const currentMins = h * 60 + m;
|
||||
|
||||
let isOpen = false;
|
||||
let rangesBJ = [];
|
||||
|
||||
for (const session of config.sessions) {
|
||||
const [sh, sm] = session.start.split(':').map(Number);
|
||||
const [eh, em] = session.end.split(':').map(Number);
|
||||
|
||||
let startMins = sh * 60 + sm;
|
||||
let endMins = eh * 60 + em;
|
||||
|
||||
// Check Status
|
||||
if (endMins < startMins) { // Cross midnight
|
||||
if (currentMins >= startMins || currentMins < endMins) isOpen = true;
|
||||
} else {
|
||||
if (currentMins >= startMins && currentMins < endMins) isOpen = true;
|
||||
}
|
||||
|
||||
// Convert to BJ Time
|
||||
const toBJ = (hh, mm) => {
|
||||
let totalMins = (hh * 60 + mm) + (diffHours * 60);
|
||||
while (totalMins >= 1440) totalMins -= 1440;
|
||||
while (totalMins < 0) totalMins += 1440;
|
||||
|
||||
const h = Math.floor(totalMins / 60);
|
||||
const m = Math.floor(totalMins % 60);
|
||||
return `${h}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
rangesBJ.push(`${toBJ(sh, sm)}-${toBJ(eh, em)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
label: rangesBJ.join(', '),
|
||||
text: isOpen ? '开市' : '休市'
|
||||
};
|
||||
};
|
||||
|
||||
const getSymbolUrl = (symbol) => {
|
||||
if (!symbol) return 'https://gu.qq.com/'
|
||||
if (symbol.startsWith('us.')) return `https://gu.qq.com/${symbol.replace('.', '')}`
|
||||
return `https://gu.qq.com/${symbol}`
|
||||
}
|
||||
|
||||
const toFiniteNumber = v => {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const normalizeMove = (row) => {
|
||||
const changeRaw = toFiniteNumber(row && row.change)
|
||||
const prevClose = toFiniteNumber(row && row.prevClose)
|
||||
const changePercentRaw = toFiniteNumber(row && row.change_percent)
|
||||
const hasDerivedPercent = changeRaw !== null && prevClose !== null && prevClose !== 0
|
||||
const changePercent = hasDerivedPercent ? (changeRaw / prevClose) * 100 : (changePercentRaw !== null ? changePercentRaw : 0)
|
||||
const basis = changeRaw !== null ? changeRaw : changePercent
|
||||
const sign = basis >= 0 ? 1 : -1
|
||||
const change = changeRaw !== null ? changeRaw : 0
|
||||
return { change, changePercent, sign }
|
||||
}
|
||||
|
||||
const fmtSign = n => `${n >= 0 ? '+' : '-'}${Math.abs(n).toFixed(2)}`
|
||||
|
||||
const buildArchiveHtml = (date, quotes) => {
|
||||
const css = `body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;padding:16px;box-sizing:border-box;width:100%;max-width:none} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;width:100%;margin:0} .card{border:1px solid #e8e8e8;border-radius:12px;padding:12px;display:grid;gap:8px;background:var(--card-bg,#ffffff);box-shadow:0 1px 4px rgba(0,0,0,0.08)} .card-overseas{--card-bg:#FAE5E5} .card-domestic{--card-bg:#F8E0FA} .card-enhanced{--card-bg:#FDF9E2} .card-defensive{--card-bg:#DADAF8} .meta{font-size:12px;color:#8c8c8c} .up{color:#f5222d;font-weight:600} .down{color:#52c41a;font-weight:600} .card-alert{border-color:#ff4d4f} .alert-flag{display:inline-block;padding:2px 8px;font-size:12px;border-radius:12px;background:#ff4d4f;color:#fff;margin-left:8px} a{text-decoration:none}`;
|
||||
|
||||
const cfg = getConfig();
|
||||
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2;
|
||||
|
||||
let cardsHTML = '';
|
||||
|
||||
const sorted = [...quotes].sort((a, b) => {
|
||||
const ao = Number.isFinite(a.card_order) ? a.card_order : 9999
|
||||
const bo = Number.isFinite(b.card_order) ? b.card_order : 9999
|
||||
if (ao !== bo) return ao - bo
|
||||
const aKey = `${a.label || ''}${a.name || ''}${a.symbol || ''}`
|
||||
const bKey = `${b.label || ''}${b.name || ''}${b.symbol || ''}`
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
for (const r of sorted) {
|
||||
let cardCls = 'card';
|
||||
let isAlert = false;
|
||||
if (r.card_type === 'overseas') cardCls += ' card-overseas';
|
||||
if (r.card_type === 'domestic') cardCls += ' card-domestic';
|
||||
if (r.card_type === 'enhanced') cardCls += ' card-enhanced';
|
||||
if (r.card_type === 'defensive') cardCls += ' card-defensive';
|
||||
|
||||
const move = normalizeMove(r)
|
||||
if (move.changePercent <= threshold) {
|
||||
cardCls += ' card-alert';
|
||||
isAlert = true;
|
||||
}
|
||||
|
||||
const changePercent = move.changePercent;
|
||||
const changeClass = move.sign >= 0 ? 'up' : 'down';
|
||||
|
||||
// Status Logic
|
||||
let statusHtml = '';
|
||||
const status = getMarketStatus(r.market_status);
|
||||
if (status) {
|
||||
const color = status.isOpen ? '#52c41a' : '#8c8c8c';
|
||||
statusHtml = ` <span style="margin-left:8px;color:${color}">● ${status.text} (北时 ${status.label})</span>`;
|
||||
}
|
||||
|
||||
// Link Logic
|
||||
const linkUrl = r.url || getSymbolUrl(r.symbol);
|
||||
const linkHtml = `<a href="${linkUrl}" target="_blank" title="查看详情" style="margin-left:8px">🏠</a>`;
|
||||
|
||||
// Alert Flag
|
||||
const alertHtml = isAlert ? `<span class="alert-flag">⚠ 告警</span>` : '';
|
||||
|
||||
const price = Number.isFinite(r.price) ? r.price : '0.00';
|
||||
const change = fmtSign(move.change);
|
||||
|
||||
cardsHTML += `
|
||||
<div class="${cardCls}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>${r.label} · ${r.name || ''} (${r.symbol}) ${alertHtml}</div>
|
||||
${linkHtml}
|
||||
</div>
|
||||
<div class="price-display" style="font-size:16px;font-weight:600">
|
||||
价格 <span style="font-weight:700">${price}</span>
|
||||
涨跌 <span class="${changeClass}">${change}</span>
|
||||
(<span class="${changeClass}" style="padding:2px 6px;border-radius:4px;background:${move.sign>=0?'#fff2f0':'#f6ffed'};border:1px solid ${move.sign>=0?'#ffccc7':'#b7eb8f'}">${fmtSign(changePercent)}%</span>)
|
||||
</div>
|
||||
<div class="meta">来源 ${r.source || ''} · 时间 ${r.captured_at || ''}${statusHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const title = `市场封存 · ${date}`;
|
||||
const time = fmtCNDateTimeSrv(new Date());
|
||||
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${title}</title><style>${css}</style></head><body><h3>${title}</h3><div class="grid">${cardsHTML}</div><div class="meta" style="margin-top:16px">生成时间 ${time}</div></body></html>`;
|
||||
}
|
||||
|
||||
const isWeekday = d => { const day = d.getDay(); return day >= 1 && day <= 5 }
|
||||
let schedulerStarted = false
|
||||
let lastArchiveSavedDay = ''
|
||||
let lastNotifySlotKey = ''
|
||||
const notifyStateRow = getMarketState.get('notify_slot')
|
||||
if (notifyStateRow && notifyStateRow.value) {
|
||||
lastNotifySlotKey = String(notifyStateRow.value)
|
||||
}
|
||||
const startScheduler = () => {
|
||||
if (schedulerStarted) return
|
||||
schedulerStarted = true
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const nowParts = fmtCNParts()
|
||||
const hour = nowParts.hour
|
||||
const minute = nowParts.minute
|
||||
const dateStr = nowParts.dateStr
|
||||
const parts = String(dateStr || '').split('-').map(x => parseInt(x, 10))
|
||||
const workdayDate = parts.length === 3 && parts.every(n => Number.isFinite(n)) ? new Date(parts[0], parts[1] - 1, parts[2]) : new Date()
|
||||
const weekday = isWeekday(workdayDate)
|
||||
let slot = ''
|
||||
if (hour >= 10 && hour < 12) slot = 'am'
|
||||
else if (hour >= 13 && hour <= 15) slot = 'pm'
|
||||
if (weekday && slot) {
|
||||
const notifySlotKey = `${dateStr}-${slot}`
|
||||
if (lastNotifySlotKey !== notifySlotKey) {
|
||||
const { quotes } = await fetchAll()
|
||||
const notified = await maybeNotify(quotes)
|
||||
if (notified) {
|
||||
lastNotifySlotKey = notifySlotKey
|
||||
upsertMarketState.run({ key: 'notify_slot', value: notifySlotKey })
|
||||
logJSON('markets.scheduler.notify', { date: dateStr, slot }, 'markets')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hour === 15 && minute >= 0 && minute <= 30) {
|
||||
if (lastArchiveSavedDay !== dateStr) {
|
||||
const existed = getMarketArchive.get(dateStr)
|
||||
if (!existed) {
|
||||
const { quotes } = await fetchAll()
|
||||
// #region debug-point C:auto-archive-before-save
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = (quotes || []).filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'C', location: 'markets.js:startScheduler:auto-archive', msg: '[DEBUG] markets auto archive before save', data: { day: dateStr, quotes_count: Array.isArray(quotes) ? quotes.length : 0, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
const html = buildArchiveHtml(dateStr, quotes)
|
||||
upsertMarketArchive.run({ day: dateStr, html })
|
||||
lastArchiveSavedDay = dateStr
|
||||
logJSON('markets.archive.auto.save', { day: dateStr, html_len: html.length }, 'markets')
|
||||
} else {
|
||||
lastArchiveSavedDay = dateStr
|
||||
logJSON('markets.archive.auto.skip', { day: dateStr, reason: 'already_saved' }, 'markets')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logJSON('markets.scheduler.error', { error: String(e.message || e) }, 'markets')
|
||||
}
|
||||
}, 10 * 60 * 1000)
|
||||
}
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
const cfg = getConfig()
|
||||
assertMarketsConfigInvariant(cfg)
|
||||
if (!cfg.email || !cfg.email.enabled) throw new Error('Email not enabled')
|
||||
const date = fmtCNParts().dateStr
|
||||
const rows = queryByDate.all(date)
|
||||
const subject = `市场通知测试 ${date}`
|
||||
const lines = rows.map(r => `${r.label} ${r.symbol} src ${r.source} ${isFinite(r.change_percent) ? r.change_percent.toFixed(2) : '0.00'}% price ${r.price}`)
|
||||
const watchlist = Array.isArray(cfg.watchlist) ? cfg.watchlist : []
|
||||
const monitorText = cfg.skills_only_mode !== false
|
||||
? 'skill_mode (src/server/market_skills)'
|
||||
: watchlist.map(w => `${w.label}:${w.symbol}`).join(', ')
|
||||
const body = [`邮件通知测试 ${fmtCNDateTimeSrv(new Date())}`, `监控项: ${monitorText}`, `当日记录: ${rows.length}`, ...lines].join('\n')
|
||||
try {
|
||||
const t = nodemailer.createTransport(cfg.email.smtp)
|
||||
await t.sendMail({ from: cfg.email.from, to: cfg.email.to.join(','), subject, text: body })
|
||||
try { insertEmailLog.run({ skill: 'markets_test', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {}
|
||||
} catch (e) {
|
||||
const errMsg = String(e.message || e)
|
||||
try { insertEmailLog.run({ skill: 'markets_test', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchAll, queryByDate, getConfig, setConfig, startScheduler, sendTestEmail, maybeNotify }
|
||||
@@ -0,0 +1,899 @@
|
||||
const axios = require('axios')
|
||||
const iconv = require('iconv-lite')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { logJSON } = require('./logger')
|
||||
|
||||
const sinaMapSymbol = s => {
|
||||
const x = String(s).toLowerCase()
|
||||
if (x === '000001' || x === 'sh000001') return 'sh000001'
|
||||
if (x === '399001' || x === 'sz399001') return 'sz399001'
|
||||
if (x === '399006' || x === 'sz399006') return 'sz399006'
|
||||
if (x === '000300' || x === 'sh000300') return 'sh000300'
|
||||
if (x === '899050' || x.startsWith('bj899050')) return 'bj899050'
|
||||
if (x === '000688' || x === 'sh000688') return 'sh000688'
|
||||
if (x === 'ixic' || x === '.ixic' || x === 'gb_ixic') return 'gb_ixic'
|
||||
if (x === 'inx' || x === '.inx' || x === 'gb_inx') return 'gb_inx'
|
||||
if (x === 'hsi.hk' || x === 'hsi' || x === 'hk_hsi' || x === 'hkhsi') return 'rt_hkHSI'
|
||||
if (x === '03153.hk' || x === 'hk03153' || x === '03153') return 'rt_hk03153'
|
||||
if (x === 'inda' || x === 'amex:inda' || x === 'us_inda' || x === 'gb_inda') return 'gb_inda'
|
||||
if (x === 'ewg' || x === 'nysearca:ewg' || x === 'us_ewg' || x === 'gb_ewg') return 'gb_ewg'
|
||||
return s
|
||||
}
|
||||
|
||||
const parseSinaLine = line => {
|
||||
const m = line.match(/^var\s+hq_str_(\w+)=\"([^\"]*)/)
|
||||
if (!m) return null
|
||||
const symbol = m[1]
|
||||
const body = m[2]
|
||||
const parts = body.split(',')
|
||||
if (symbol.startsWith('gb_')) {
|
||||
const name = parts[0] || symbol
|
||||
const price = parseFloat(parts[1] || '0')
|
||||
// find percent in any field
|
||||
const pctField = parts.find(p => /%/.test(p)) || ''
|
||||
const pctMatch = String(pctField).match(/(-?\d+(?:\.\d+)?)%/)
|
||||
const pct = pctMatch ? parseFloat(pctMatch[1]) : 0
|
||||
let prevClose = 0, change = 0
|
||||
if (pct !== 0 && price) { prevClose = price / (1 + pct / 100); change = price - prevClose }
|
||||
return { symbol, name, price, prevClose, change, change_percent: pct }
|
||||
}
|
||||
if (symbol.startsWith('hk')) {
|
||||
const name = parts[0] || symbol
|
||||
const price = parseFloat(parts[6] || parts[1] || '0')
|
||||
const prevClose = parseFloat(parts[3] || parts[2] || '0')
|
||||
const change = price - prevClose
|
||||
const pct = prevClose ? (change / prevClose) * 100 : 0
|
||||
return { symbol, name, price, prevClose, change, change_percent: pct }
|
||||
}
|
||||
const name = parts[0] || ''
|
||||
const price = parseFloat(parts[3] || parts[1] || '0')
|
||||
const prevClose = parseFloat(parts[2] || '0')
|
||||
const change = price - prevClose
|
||||
const pct = prevClose ? (change / prevClose) * 100 : 0
|
||||
return { symbol, name, price, prevClose, change, change_percent: pct }
|
||||
}
|
||||
|
||||
const fetchSina = async symbols => {
|
||||
if (!symbols || symbols.length === 0) return []
|
||||
const chunkSize = 6
|
||||
const outMap = new Map()
|
||||
for (let i = 0; i < symbols.length; i += chunkSize) {
|
||||
const chunk = symbols.slice(i, i + chunkSize).map(sinaMapSymbol)
|
||||
const list = chunk.join(',')
|
||||
const url = `http://hq.sinajs.cn/list=${list}`
|
||||
logJSON('markets.http.get', { source: 'sina', url, symbols: chunk, chunk_index: Math.floor(i / chunkSize), chunk_size: chunk.length }, 'markets')
|
||||
try {
|
||||
const res = await axios.get(url, {
|
||||
headers: {
|
||||
'Referer': 'https://finance.sina.com.cn/',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9'
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000,
|
||||
validateStatus: () => true
|
||||
})
|
||||
if (!res || res.status >= 400) {
|
||||
logJSON('markets.http.status', { source: 'sina', url, status: res && res.status, chunk_index: Math.floor(i / chunkSize) }, 'markets')
|
||||
continue
|
||||
}
|
||||
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
|
||||
const lines = String(text || '').split(/;\s*/).filter(Boolean)
|
||||
let parsedCount = 0
|
||||
for (const line of lines) {
|
||||
const r = parseSinaLine(line)
|
||||
if (r) {
|
||||
parsedCount += 1
|
||||
outMap.set(r.symbol, { ...r, source: 'sina' })
|
||||
}
|
||||
}
|
||||
logJSON('markets.http.parse', { source: 'sina', chunk_index: Math.floor(i / chunkSize), count: parsedCount }, 'markets')
|
||||
} catch (e) {
|
||||
logJSON('markets.http.error', { source: 'sina', url, chunk_index: Math.floor(i / chunkSize), error: String(e.message || e) }, 'markets')
|
||||
}
|
||||
}
|
||||
return Array.from(outMap.values())
|
||||
}
|
||||
|
||||
const marketsConfigPath = path.join(process.cwd(), 'config', 'markets.json')
|
||||
const yahooProbeState = {
|
||||
quoteCache: new Map(),
|
||||
chartCache: new Map(),
|
||||
quotePendingSymbols: new Set(),
|
||||
quotePendingWaiters: [],
|
||||
quoteFlushTimer: null,
|
||||
quoteFlushRunning: false,
|
||||
chartInflight: new Map(),
|
||||
lastRequestAt: { quote: 0, chart: 0 },
|
||||
finnhubLastRequestAt: 0,
|
||||
finnhubRequestTimes: []
|
||||
}
|
||||
const YAHOO_PROBE_SYMBOL_ALIAS = Object.freeze({
|
||||
usINX: '^GSPC',
|
||||
usIXIC: '^IXIC',
|
||||
usDJI: '^DJI',
|
||||
hkHSI: '^HSI',
|
||||
zsN225: '^N225',
|
||||
zsGDAXI: '^GDAXI',
|
||||
zstwii: '^TWII',
|
||||
zskospi: '^KS11',
|
||||
zsBVSP: '^BVSP',
|
||||
zsSTI: '^STI',
|
||||
usEWG: 'EWG',
|
||||
usFLSA: 'FLSA',
|
||||
usNFTY: 'NFTY',
|
||||
usREMX: 'REMX',
|
||||
usEWJ: 'EWJ',
|
||||
usEWY: 'EWY',
|
||||
usEWZ: 'EWZ',
|
||||
usASEA: 'ASEA',
|
||||
usEWT: 'EWT',
|
||||
usINDA: 'INDA',
|
||||
hf_GC: 'GC=F',
|
||||
hf_SI: 'SI=F'
|
||||
})
|
||||
const FINNHUB_TIMEZONE_ALIAS = Object.freeze({
|
||||
'^GSPC': 'America/New_York',
|
||||
'^IXIC': 'America/New_York',
|
||||
'^DJI': 'America/New_York',
|
||||
'^HSI': 'Asia/Hong_Kong',
|
||||
'^N225': 'Asia/Tokyo',
|
||||
'^GDAXI': 'Europe/Berlin',
|
||||
'^TWII': 'Asia/Taipei',
|
||||
'^KS11': 'Asia/Seoul',
|
||||
'^BVSP': 'America/Sao_Paulo',
|
||||
'^STI': 'Asia/Singapore'
|
||||
})
|
||||
const FINNHUB_DEFAULT_TZ = 'America/New_York'
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const toFiniteNumber = value => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const pushYahooTrace = (trace, stage, details = {}) => {
|
||||
if (!Array.isArray(trace)) return
|
||||
trace.push({
|
||||
time: new Date().toISOString(),
|
||||
stage,
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
const readMarketsConfig = () => {
|
||||
try {
|
||||
if (!fs.existsSync(marketsConfigPath)) return {}
|
||||
return JSON.parse(fs.readFileSync(marketsConfigPath, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const getYahooProbeConfig = () => {
|
||||
const cfg = readMarketsConfig()
|
||||
const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {}
|
||||
const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '')
|
||||
return {
|
||||
enabled: raw.enabled !== false,
|
||||
base_url: baseUrl || 'http://47.252.30.127:19071',
|
||||
token: String(raw.token || '').trim(),
|
||||
quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
|
||||
chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
|
||||
finnhub_quote_endpoint: String(raw.finnhub_quote_endpoint || '/probe/finnhub/quote').trim() || '/probe/finnhub/quote',
|
||||
finnhub_candle_endpoint: String(raw.finnhub_candle_endpoint || '/probe/finnhub/candle').trim() || '/probe/finnhub/candle',
|
||||
request_timeout_ms: Math.max(3000, Number(raw.request_timeout_ms || 15000)),
|
||||
quote_ttl_seconds: Math.max(15, Number(raw.quote_ttl_seconds || 120)),
|
||||
chart_ttl_seconds: Math.max(300, Number(raw.chart_ttl_seconds || 21600)),
|
||||
quote_batch_size: Math.max(1, Math.min(20, Number(raw.quote_batch_size || 4))),
|
||||
quote_min_interval_ms: Math.max(0, Number(raw.quote_min_interval_ms || raw.min_request_interval_ms || 2500)),
|
||||
chart_min_interval_ms: Math.max(0, Number(raw.chart_min_interval_ms || raw.min_request_interval_ms || 2500)),
|
||||
finnhub_min_interval_ms: Math.max(1200, Number(raw.finnhub_min_interval_ms || 1300)),
|
||||
finnhub_max_requests_per_minute: Math.max(1, Math.min(55, Number(raw.finnhub_max_requests_per_minute || 50)))
|
||||
}
|
||||
}
|
||||
|
||||
const getYahooProbeCache = (cache, key) => {
|
||||
const hit = cache.get(key)
|
||||
if (!hit) return null
|
||||
if (Number(hit.expires_at || 0) <= Date.now()) {
|
||||
cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return hit.value
|
||||
}
|
||||
|
||||
const setYahooProbeCache = (cache, key, value, ttlSeconds) => {
|
||||
cache.set(key, {
|
||||
value,
|
||||
expires_at: Date.now() + Math.max(1, ttlSeconds) * 1000
|
||||
})
|
||||
}
|
||||
|
||||
const getYahooProbeSymbol = symbol => {
|
||||
const raw = String(symbol || '').trim()
|
||||
if (!raw) return ''
|
||||
if (YAHOO_PROBE_SYMBOL_ALIAS[raw]) return YAHOO_PROBE_SYMBOL_ALIAS[raw]
|
||||
if (/^us[A-Z][A-Z0-9._-]+$/.test(raw)) return raw.slice(2)
|
||||
return raw
|
||||
}
|
||||
|
||||
const getFinnhubSymbol = symbol => getYahooProbeSymbol(symbol)
|
||||
|
||||
const getFinnhubTimeZone = symbol => {
|
||||
const key = String(symbol || '').trim()
|
||||
return FINNHUB_TIMEZONE_ALIAS[key] || FINNHUB_DEFAULT_TZ
|
||||
}
|
||||
|
||||
const sleepUntilNextMinuteWindow = async cfg => {
|
||||
const windowMs = 60 * 1000
|
||||
const now = Date.now()
|
||||
yahooProbeState.finnhubRequestTimes = yahooProbeState.finnhubRequestTimes.filter(ts => (now - ts) < windowMs)
|
||||
const maxPerMinute = Math.max(1, Number(cfg.finnhub_max_requests_per_minute || 50))
|
||||
if (yahooProbeState.finnhubRequestTimes.length < maxPerMinute) return
|
||||
const oldest = Number(yahooProbeState.finnhubRequestTimes[0] || 0)
|
||||
const waitMs = Math.max(0, windowMs - (now - oldest) + 50)
|
||||
if (waitMs > 0) await sleep(waitMs)
|
||||
}
|
||||
|
||||
const throttleFinnhubProbe = async (cfg, trace, reason) => {
|
||||
await sleepUntilNextMinuteWindow(cfg)
|
||||
const minInterval = Math.max(0, Number(cfg.finnhub_min_interval_ms || 1300))
|
||||
const waitMs = Math.max(0, minInterval - (Date.now() - Number(yahooProbeState.finnhubLastRequestAt || 0)))
|
||||
if (waitMs > 0) {
|
||||
pushYahooTrace(trace, 'finnhub.throttle_wait', { wait_ms: waitMs, reason: String(reason || '') })
|
||||
await sleep(waitMs)
|
||||
}
|
||||
const now = Date.now()
|
||||
yahooProbeState.finnhubLastRequestAt = now
|
||||
yahooProbeState.finnhubRequestTimes.push(now)
|
||||
yahooProbeState.finnhubRequestTimes = yahooProbeState.finnhubRequestTimes.filter(ts => (now - ts) < 60 * 1000)
|
||||
}
|
||||
|
||||
const shiftDateByRange = (date, amount, unit) => {
|
||||
const next = new Date(date.getTime())
|
||||
if (unit === 'd') next.setUTCDate(next.getUTCDate() - amount)
|
||||
if (unit === 'mo') next.setUTCMonth(next.getUTCMonth() - amount)
|
||||
if (unit === 'y') next.setUTCFullYear(next.getUTCFullYear() - amount)
|
||||
return next
|
||||
}
|
||||
|
||||
const convertYahooRangeToUnixWindow = range => {
|
||||
const now = new Date()
|
||||
const text = String(range || '1y').trim().toLowerCase()
|
||||
let fromDate = shiftDateByRange(now, 1, 'y')
|
||||
if (text === 'ytd') {
|
||||
fromDate = new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0))
|
||||
} else {
|
||||
const match = text.match(/^(\d+)(d|mo|y)$/)
|
||||
if (match) {
|
||||
fromDate = shiftDateByRange(now, Number(match[1] || 1), match[2])
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: Math.max(0, Math.floor(fromDate.getTime() / 1000)),
|
||||
to: Math.max(0, Math.floor(now.getTime() / 1000))
|
||||
}
|
||||
}
|
||||
|
||||
const mapYahooIntervalToFinnhubResolution = interval => {
|
||||
const text = String(interval || '1d').trim().toLowerCase()
|
||||
if (text === '1d' || text === '1day' || text === 'd') return 'D'
|
||||
if (text === '1wk' || text === '1w' || text === 'wk' || text === 'w') return 'W'
|
||||
if (text === '1mo' || text === '1mth' || text === 'mo' || text === 'm') return 'M'
|
||||
if (text === '60m' || text === '1h' || text === '60') return '60'
|
||||
if (text === '30m' || text === '30') return '30'
|
||||
if (text === '15m' || text === '15') return '15'
|
||||
if (text === '5m' || text === '5') return '5'
|
||||
if (text === '1m' || text === '1') return '1'
|
||||
return 'D'
|
||||
}
|
||||
|
||||
const scheduleYahooQuoteFlush = () => {
|
||||
if (yahooProbeState.quoteFlushTimer) return
|
||||
yahooProbeState.quoteFlushTimer = setTimeout(() => {
|
||||
yahooProbeState.quoteFlushTimer = null
|
||||
flushYahooQuoteQueue().catch(() => {})
|
||||
}, 15)
|
||||
}
|
||||
|
||||
const requestYahooQuoteBatch = async (batch, cfg) => {
|
||||
const mappings = batch.map(original => ({ original, yahoo: getYahooProbeSymbol(original) }))
|
||||
await throttleYahooProbe('quote', cfg, null)
|
||||
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.quote_endpoint}`
|
||||
// #region debug-point D:quote-batch-request
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'D', location: 'marketsClient.js:requestYahooQuoteBatch:request', msg: '[DEBUG] yahoo quote batch request', data: { url, batch, yahoo_symbols: mappings.map(item => item.yahoo), batch_size: batch.length }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
logJSON('markets.http.get', {
|
||||
source: 'yahoo_probe',
|
||||
url,
|
||||
symbols: batch,
|
||||
yahoo_symbols: mappings.map(item => item.yahoo),
|
||||
batch_size: batch.length
|
||||
}, 'markets')
|
||||
const resp = await axios.get(url, {
|
||||
timeout: cfg.request_timeout_ms,
|
||||
validateStatus: () => true,
|
||||
params: {
|
||||
token: cfg.token,
|
||||
type: 'quote',
|
||||
symbols: mappings.map(item => item.yahoo).join(',')
|
||||
}
|
||||
})
|
||||
if (resp && resp.status === 429) {
|
||||
pushYahooTrace(null, 'yahoo.quote.http_429', { symbols: batch, yahoo_symbols: mappings.map(item => item.yahoo) })
|
||||
logJSON('markets.http.status', { source: 'yahoo_probe', url, status: 429, symbols: batch, fallback: 'finnhub_probe_quote' }, 'markets')
|
||||
return requestFinnhubQuoteBatchSerial(mappings, cfg)
|
||||
}
|
||||
if (!resp || resp.status >= 400) {
|
||||
throw new Error(`yahoo_probe_http_${resp && resp.status ? resp.status : 'ERR'}`)
|
||||
}
|
||||
if (!resp.data) throw new Error('yahoo_probe_invalid_response')
|
||||
const rows = parseYahooQuotePayload(resp.data, mappings)
|
||||
// #region debug-point D:quote-batch-response
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'D', location: 'marketsClient.js:requestYahooQuoteBatch:response', msg: '[DEBUG] yahoo quote batch response', data: { batch, status: resp.status, rows_count: rows.length, row_symbols: rows.map(item => item.symbol), row_yahoo_symbols: rows.map(item => item.yahoo_symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
return rows
|
||||
}
|
||||
|
||||
const requestFinnhubQuoteSingle = async (mapping, cfg) => {
|
||||
const finnhubSymbol = getFinnhubSymbol(mapping && mapping.yahoo)
|
||||
if (!finnhubSymbol) throw new Error('finnhub_symbol_required')
|
||||
await throttleFinnhubProbe(cfg, null, 'quote')
|
||||
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.finnhub_quote_endpoint}`
|
||||
logJSON('markets.http.get', {
|
||||
source: 'finnhub_probe_quote',
|
||||
url,
|
||||
symbol: mapping.original,
|
||||
yahoo_symbol: mapping.yahoo,
|
||||
finnhub_symbol: finnhubSymbol
|
||||
}, 'markets')
|
||||
const resp = await axios.get(url, {
|
||||
timeout: cfg.request_timeout_ms,
|
||||
validateStatus: () => true,
|
||||
params: {
|
||||
token: cfg.token,
|
||||
symbol: finnhubSymbol
|
||||
}
|
||||
})
|
||||
if (!resp || resp.status >= 400) throw new Error(`finnhub_probe_quote_http_${resp && resp.status ? resp.status : 'ERR'}`)
|
||||
const data = resp.data && typeof resp.data === 'object' ? resp.data : {}
|
||||
const price = toFiniteNumber(data && data.c)
|
||||
if (!Number.isFinite(price) || price <= 0) throw new Error('finnhub_probe_quote_invalid_response')
|
||||
const prevClose = toFiniteNumber(data && data.pc)
|
||||
const change = toFiniteNumber(data && data.d)
|
||||
const pct = toFiniteNumber(data && data.dp)
|
||||
const ts = toFiniteNumber(data && data.t)
|
||||
return normalizeYahooQuoteItem({
|
||||
symbol: mapping.original,
|
||||
yahoo_symbol: mapping.yahoo,
|
||||
name: mapping.original,
|
||||
price,
|
||||
prevClose: prevClose != null ? prevClose : 0,
|
||||
change: change != null ? change : (prevClose != null ? price - prevClose : 0),
|
||||
change_percent: pct != null ? pct : (prevClose ? ((price - prevClose) / prevClose) * 100 : 0),
|
||||
regular_market_time: ts,
|
||||
exchange_timezone_name: getFinnhubTimeZone(finnhubSymbol),
|
||||
source: 'finnhub_probe'
|
||||
}, {
|
||||
originalSymbol: mapping.original,
|
||||
yahooSymbol: mapping.yahoo,
|
||||
source: 'finnhub_probe'
|
||||
})
|
||||
}
|
||||
|
||||
const requestFinnhubQuoteBatchSerial = async (mappings, cfg) => {
|
||||
const rows = []
|
||||
for (const mapping of (Array.isArray(mappings) ? mappings : [])) {
|
||||
try {
|
||||
const row = await requestFinnhubQuoteSingle(mapping, cfg)
|
||||
if (row && row.symbol) rows.push(row)
|
||||
} catch (error) {
|
||||
const symbol = String(mapping && mapping.original || '').trim()
|
||||
const cached = symbol ? getYahooProbeCache(yahooProbeState.quoteCache, symbol) : null
|
||||
const message = String(error && error.message ? error.message : error)
|
||||
logJSON('markets.http.error', {
|
||||
source: 'finnhub_probe_quote',
|
||||
symbol,
|
||||
yahoo_symbol: String(mapping && mapping.yahoo || '').trim(),
|
||||
error: message,
|
||||
fallback: cached && cached.symbol ? 'quote_cache' : 'skip_symbol'
|
||||
}, 'markets')
|
||||
if (cached && cached.symbol) rows.push(cached)
|
||||
}
|
||||
}
|
||||
logJSON('markets.http.parse', { source: 'finnhub_probe_quote', count: rows.length, requested: Array.isArray(mappings) ? mappings.length : 0 }, 'markets')
|
||||
return rows
|
||||
}
|
||||
|
||||
const flushYahooQuoteQueue = async () => {
|
||||
if (yahooProbeState.quoteFlushRunning) return
|
||||
if (yahooProbeState.quotePendingWaiters.length === 0) return
|
||||
yahooProbeState.quoteFlushRunning = true
|
||||
try {
|
||||
while (yahooProbeState.quotePendingWaiters.length > 0) {
|
||||
const waiters = yahooProbeState.quotePendingWaiters.splice(0)
|
||||
const pendingSymbols = Array.from(yahooProbeState.quotePendingSymbols)
|
||||
yahooProbeState.quotePendingSymbols.clear()
|
||||
// #region debug-point A:quote-flush-begin
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'A', location: 'marketsClient.js:flushYahooQuoteQueue:begin', msg: '[DEBUG] yahoo quote flush begin', data: { waiter_count: waiters.length, pending_symbol_count: pendingSymbols.length, pending_symbols: pendingSymbols }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
if (pendingSymbols.length === 0) {
|
||||
for (const waiter of waiters) waiter.resolve([])
|
||||
continue
|
||||
}
|
||||
const cfg = getYahooProbeConfig()
|
||||
const batchSize = Math.max(1, Number(cfg.quote_batch_size || 4))
|
||||
const outMap = new Map()
|
||||
for (let i = 0; i < pendingSymbols.length; i += batchSize) {
|
||||
const batch = pendingSymbols.slice(i, i + batchSize)
|
||||
try {
|
||||
const rows = await requestYahooQuoteBatch(batch, cfg)
|
||||
for (const row of rows) {
|
||||
if (!row || !row.symbol) continue
|
||||
outMap.set(row.symbol, row)
|
||||
setYahooProbeCache(yahooProbeState.quoteCache, row.symbol, row, cfg.quote_ttl_seconds)
|
||||
}
|
||||
} catch (e) {
|
||||
// #region debug-point A:quote-batch-error
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e2 = fs.readFileSync(p, 'utf8'); u = (e2.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e2.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'A', location: 'marketsClient.js:flushYahooQuoteQueue:error', msg: '[DEBUG] yahoo quote batch error', data: { batch, error: String(e && e.message ? e.message : e) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
logJSON('markets.http.error', { source: 'yahoo_probe', symbols: batch, error: String(e.message || e) }, 'markets')
|
||||
}
|
||||
}
|
||||
for (const waiter of waiters) {
|
||||
waiter.resolve(waiter.symbols.map(symbol => outMap.get(symbol)).filter(Boolean))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
yahooProbeState.quoteFlushRunning = false
|
||||
if (yahooProbeState.quotePendingWaiters.length > 0) scheduleYahooQuoteFlush()
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueYahooQuoteRequest = symbols => new Promise(resolve => {
|
||||
const wanted = Array.from(new Set((symbols || []).map(s => String(s || '').trim()).filter(Boolean)))
|
||||
yahooProbeState.quotePendingWaiters.push({ symbols: wanted, resolve })
|
||||
for (const symbol of wanted) yahooProbeState.quotePendingSymbols.add(symbol)
|
||||
scheduleYahooQuoteFlush()
|
||||
})
|
||||
|
||||
const formatDateTime = (date, timeZone) => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}).formatToParts(date)
|
||||
const get = type => parts.find(x => x.type === type)?.value || '00'
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const unixToDateTime = (sec, timeZone) => {
|
||||
const s = Number(sec)
|
||||
if (!Number.isFinite(s) || s <= 0) return ''
|
||||
return formatDateTime(new Date(s * 1000), timeZone || 'America/New_York')
|
||||
}
|
||||
|
||||
const unixToDate = (sec, timeZone) => {
|
||||
const dt = unixToDateTime(sec, timeZone)
|
||||
return dt ? dt.slice(0, 10) : ''
|
||||
}
|
||||
|
||||
const approxAmount = (close, volume) => {
|
||||
const c = toFiniteNumber(close)
|
||||
const v = toFiniteNumber(volume)
|
||||
if (!Number.isFinite(c) || !Number.isFinite(v) || c <= 0 || v <= 0) return null
|
||||
return c * v
|
||||
}
|
||||
|
||||
const throttleYahooProbe = async (kind, cfg, trace) => {
|
||||
const key = kind === 'chart' ? 'chart' : 'quote'
|
||||
const minInterval = key === 'chart' ? cfg.chart_min_interval_ms : cfg.quote_min_interval_ms
|
||||
const lastAt = Number(yahooProbeState.lastRequestAt[key] || 0)
|
||||
const waitMs = Math.max(0, minInterval - (Date.now() - lastAt))
|
||||
if (waitMs > 0) {
|
||||
pushYahooTrace(trace, 'yahoo.throttle_wait', { kind: key, wait_ms: waitMs })
|
||||
await sleep(waitMs)
|
||||
}
|
||||
yahooProbeState.lastRequestAt[key] = Date.now()
|
||||
}
|
||||
|
||||
const normalizeYahooQuoteItem = (item, options = {}) => {
|
||||
const originalSymbol = String(options.originalSymbol || (item && item.symbol) || '').trim()
|
||||
const yahooSymbol = String(options.yahooSymbol || (item && item.yahoo_symbol) || (item && item.symbol) || '').trim()
|
||||
const price = toFiniteNumber(item && (item.price != null ? item.price : item.regularMarketPrice))
|
||||
const prevClose = toFiniteNumber(item && (item.prevClose != null ? item.prevClose : item.regularMarketPreviousClose))
|
||||
const change = toFiniteNumber(item && (item.change != null ? item.change : item.regularMarketChange))
|
||||
const pct = toFiniteNumber(item && (item.change_percent != null ? item.change_percent : item.regularMarketChangePercent))
|
||||
const epoch = toFiniteNumber(item && (item.regular_market_time != null ? item.regular_market_time : item.regularMarketTime))
|
||||
const exchangeTz = String(item && (item.exchange_timezone_name || item.exchangeTimezoneName || 'America/New_York') || 'America/New_York')
|
||||
const tradeAt = String(item && item.last_trade_at || '').trim() || unixToDateTime(epoch, exchangeTz)
|
||||
const beijingAt = String(item && item.last_trade_beijing_at || '').trim() || unixToDateTime(epoch, 'Asia/Shanghai')
|
||||
return {
|
||||
symbol: originalSymbol,
|
||||
yahoo_symbol: yahooSymbol,
|
||||
name: String(item && (item.name || item.longName || item.shortName || item.displayName || item.yahoo_symbol || item.symbol) || '').trim(),
|
||||
price: price != null ? price : 0,
|
||||
prevClose: prevClose != null ? prevClose : 0,
|
||||
change: change != null ? change : (price != null && prevClose != null ? price - prevClose : 0),
|
||||
change_percent: pct != null ? pct : (price != null && prevClose ? ((price - prevClose) / prevClose) * 100 : 0),
|
||||
last_trade_date: String(item && item.last_trade_date || '').trim() || (tradeAt ? tradeAt.slice(0, 10) : ''),
|
||||
last_trade_at: tradeAt,
|
||||
last_trade_beijing_at: beijingAt,
|
||||
captured_at: String(item && item.captured_at || '').trim(),
|
||||
source: String(item && item.source || options.source || 'yahoo_probe').trim() || 'yahoo_probe'
|
||||
}
|
||||
}
|
||||
|
||||
const attachYahooPrevClose = rows => {
|
||||
let prevClose = null
|
||||
return (Array.isArray(rows) ? rows : []).map(row => {
|
||||
const close = toFiniteNumber(row && row.close)
|
||||
const output = {
|
||||
...row,
|
||||
open: toFiniteNumber(row && row.open),
|
||||
high: toFiniteNumber(row && row.high),
|
||||
low: toFiniteNumber(row && row.low),
|
||||
close,
|
||||
volume: toFiniteNumber(row && row.volume),
|
||||
amount: toFiniteNumber(row && row.amount),
|
||||
prev_close: prevClose,
|
||||
source: 'yahoo_probe'
|
||||
}
|
||||
if (Number.isFinite(close)) prevClose = close
|
||||
return output
|
||||
})
|
||||
}
|
||||
|
||||
const buildYahooChartPrevClose = items => {
|
||||
const rows = (Array.isArray(items) ? items : []).map(row => ({
|
||||
symbol: String(row && row.symbol || '').trim(),
|
||||
yahoo_symbol: String(row && row.yahoo_symbol || '').trim(),
|
||||
trade_date: String(row && row.trade_date || '').trim(),
|
||||
trade_at: String(row && row.trade_at || '').trim(),
|
||||
beijing_date: String(row && row.beijing_date || '').trim(),
|
||||
beijing_at: String(row && row.beijing_at || '').trim(),
|
||||
open: toFiniteNumber(row && row.open),
|
||||
high: toFiniteNumber(row && row.high),
|
||||
low: toFiniteNumber(row && row.low),
|
||||
close: toFiniteNumber(row && row.close),
|
||||
volume: toFiniteNumber(row && row.volume),
|
||||
amount: toFiniteNumber(row && row.amount),
|
||||
source: 'yahoo_probe'
|
||||
}))
|
||||
return attachYahooPrevClose(rows)
|
||||
}
|
||||
|
||||
const parseYahooQuotePayload = (payload, mappings) => {
|
||||
if (payload && Array.isArray(payload.items)) {
|
||||
return payload.items.map(item => normalizeYahooQuoteItem(item))
|
||||
}
|
||||
const rows = payload && payload.quoteResponse && Array.isArray(payload.quoteResponse.result)
|
||||
? payload.quoteResponse.result
|
||||
: []
|
||||
const reverse = new Map((Array.isArray(mappings) ? mappings : []).map(item => [String(item.yahoo || '').toUpperCase(), item]))
|
||||
return rows.map(item => {
|
||||
const hit = reverse.get(String(item && item.symbol || '').toUpperCase())
|
||||
return normalizeYahooQuoteItem(item, {
|
||||
originalSymbol: hit ? hit.original : String(item && item.symbol || '').trim(),
|
||||
yahooSymbol: hit ? hit.yahoo : String(item && item.symbol || '').trim()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fetchYahoo = async symbols => {
|
||||
if (!symbols || symbols.length === 0) return []
|
||||
const cfg = getYahooProbeConfig()
|
||||
const trace = Array.isArray(arguments[1] && arguments[1].trace) ? arguments[1].trace : null
|
||||
if (!cfg.enabled) {
|
||||
pushYahooTrace(trace, 'yahoo.disabled', { reason: 'probe_disabled' })
|
||||
return []
|
||||
}
|
||||
|
||||
const wanted = Array.from(new Set((symbols || []).map(s => String(s || '').trim()).filter(Boolean)))
|
||||
const outMap = new Map()
|
||||
const missing = []
|
||||
for (const symbol of wanted) {
|
||||
const cached = getYahooProbeCache(yahooProbeState.quoteCache, symbol)
|
||||
if (cached) {
|
||||
outMap.set(symbol, cached)
|
||||
pushYahooTrace(trace, 'yahoo.quote.cache_hit', { symbol })
|
||||
} else {
|
||||
missing.push(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
pushYahooTrace(trace, 'yahoo.quote.enqueue', { symbols: missing, requested: wanted.length })
|
||||
const rows = await enqueueYahooQuoteRequest(missing)
|
||||
for (const row of rows) {
|
||||
if (!row || !row.symbol) continue
|
||||
outMap.set(row.symbol, row)
|
||||
}
|
||||
pushYahooTrace(trace, 'yahoo.quote.batch_ok', { symbols: missing, count: rows.length, cached: false })
|
||||
}
|
||||
|
||||
const parsed = wanted.map(symbol => outMap.get(symbol)).filter(Boolean)
|
||||
logJSON('markets.http.parse', { source: 'yahoo_probe', count: parsed.length, requested: wanted.length }, 'markets')
|
||||
return parsed
|
||||
}
|
||||
|
||||
const parseFinnhubCandlePayload = (payload, mapping) => {
|
||||
const status = String(payload && payload.s || '').trim().toLowerCase()
|
||||
if (status !== 'ok') throw new Error(`finnhub_probe_candle_invalid_${status || 'response'}`)
|
||||
const timestamps = Array.isArray(payload && payload.t) ? payload.t : []
|
||||
const openArr = Array.isArray(payload && payload.o) ? payload.o : []
|
||||
const highArr = Array.isArray(payload && payload.h) ? payload.h : []
|
||||
const lowArr = Array.isArray(payload && payload.l) ? payload.l : []
|
||||
const closeArr = Array.isArray(payload && payload.c) ? payload.c : []
|
||||
const volumeArr = Array.isArray(payload && payload.v) ? payload.v : []
|
||||
const tz = getFinnhubTimeZone(mapping && mapping.yahoo)
|
||||
const rows = []
|
||||
for (let i = 0; i < timestamps.length; i += 1) {
|
||||
const ts = toFiniteNumber(timestamps[i])
|
||||
const close = toFiniteNumber(closeArr[i])
|
||||
if (!Number.isFinite(ts) || !Number.isFinite(close)) continue
|
||||
const tradeAt = unixToDateTime(ts, tz)
|
||||
const beijingAt = unixToDateTime(ts, 'Asia/Shanghai')
|
||||
rows.push({
|
||||
symbol: mapping.original,
|
||||
yahoo_symbol: mapping.yahoo,
|
||||
trade_date: unixToDate(ts, tz),
|
||||
trade_at: tradeAt,
|
||||
beijing_date: beijingAt ? beijingAt.slice(0, 10) : '',
|
||||
beijing_at: beijingAt,
|
||||
open: toFiniteNumber(openArr[i]),
|
||||
high: toFiniteNumber(highArr[i]),
|
||||
low: toFiniteNumber(lowArr[i]),
|
||||
close,
|
||||
volume: toFiniteNumber(volumeArr[i]),
|
||||
amount: approxAmount(close, volumeArr[i]),
|
||||
source: 'finnhub_probe'
|
||||
})
|
||||
}
|
||||
return attachYahooPrevClose(rows.map(row => ({ ...row, source: 'finnhub_probe' })))
|
||||
}
|
||||
|
||||
const requestFinnhubCandle = async (mapping, options, cfg) => {
|
||||
const finnhubSymbol = getFinnhubSymbol(mapping && mapping.yahoo)
|
||||
if (!finnhubSymbol) throw new Error('finnhub_symbol_required')
|
||||
await throttleFinnhubProbe(cfg, options && options.trace, 'chart')
|
||||
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.finnhub_candle_endpoint}`
|
||||
const unixRange = convertYahooRangeToUnixWindow(options && options.range)
|
||||
const resolution = mapYahooIntervalToFinnhubResolution(options && options.interval)
|
||||
logJSON('markets.http.get', {
|
||||
source: 'finnhub_probe_candle',
|
||||
url,
|
||||
symbol: mapping.original,
|
||||
yahoo_symbol: mapping.yahoo,
|
||||
finnhub_symbol: finnhubSymbol,
|
||||
resolution,
|
||||
from: unixRange.from,
|
||||
to: unixRange.to
|
||||
}, 'markets')
|
||||
const resp = await axios.get(url, {
|
||||
timeout: cfg.request_timeout_ms,
|
||||
validateStatus: () => true,
|
||||
params: {
|
||||
token: cfg.token,
|
||||
symbol: finnhubSymbol,
|
||||
resolution,
|
||||
from: unixRange.from,
|
||||
to: unixRange.to
|
||||
}
|
||||
})
|
||||
if (!resp || resp.status >= 400) throw new Error(`finnhub_probe_candle_http_${resp && resp.status ? resp.status : 'ERR'}`)
|
||||
const data = resp.data && typeof resp.data === 'object' ? resp.data : {}
|
||||
const rows = parseFinnhubCandlePayload(data, mapping)
|
||||
logJSON('markets.http.parse', { source: 'finnhub_probe_candle', symbol: mapping.original, count: rows.length }, 'markets')
|
||||
return rows
|
||||
}
|
||||
|
||||
const parseYahooChartPayload = (payload, mapping) => {
|
||||
if (payload && Array.isArray(payload.items)) return buildYahooChartPrevClose(payload.items)
|
||||
const result = payload && payload.chart && Array.isArray(payload.chart.result) ? payload.chart.result[0] : null
|
||||
const error = payload && payload.chart ? payload.chart.error : null
|
||||
if (!result) {
|
||||
throw new Error(`yahoo_probe_chart_invalid_${String(error && error.description || 'response')}`)
|
||||
}
|
||||
const meta = result.meta || {}
|
||||
const tz = String(meta.exchangeTimezoneName || 'America/New_York')
|
||||
const timestamps = Array.isArray(result.timestamp) ? result.timestamp : []
|
||||
const quote = result.indicators && Array.isArray(result.indicators.quote) ? result.indicators.quote[0] : {}
|
||||
const openArr = Array.isArray(quote.open) ? quote.open : []
|
||||
const highArr = Array.isArray(quote.high) ? quote.high : []
|
||||
const lowArr = Array.isArray(quote.low) ? quote.low : []
|
||||
const closeArr = Array.isArray(quote.close) ? quote.close : []
|
||||
const volumeArr = Array.isArray(quote.volume) ? quote.volume : []
|
||||
const rows = []
|
||||
for (let i = 0; i < timestamps.length; i += 1) {
|
||||
const ts = toFiniteNumber(timestamps[i])
|
||||
const close = toFiniteNumber(closeArr[i])
|
||||
if (!Number.isFinite(ts) || !Number.isFinite(close)) continue
|
||||
const tradeAt = unixToDateTime(ts, tz)
|
||||
const beijingAt = unixToDateTime(ts, 'Asia/Shanghai')
|
||||
rows.push({
|
||||
symbol: mapping.original,
|
||||
yahoo_symbol: mapping.yahoo,
|
||||
trade_date: unixToDate(ts, tz),
|
||||
trade_at: tradeAt,
|
||||
beijing_date: beijingAt ? beijingAt.slice(0, 10) : '',
|
||||
beijing_at: beijingAt,
|
||||
open: toFiniteNumber(openArr[i]),
|
||||
high: toFiniteNumber(highArr[i]),
|
||||
low: toFiniteNumber(lowArr[i]),
|
||||
close,
|
||||
volume: toFiniteNumber(volumeArr[i]),
|
||||
amount: approxAmount(close, volumeArr[i]),
|
||||
source: 'yahoo_probe'
|
||||
})
|
||||
}
|
||||
return attachYahooPrevClose(rows)
|
||||
}
|
||||
|
||||
const fetchYahooChart = async (symbol, options = {}) => {
|
||||
const rawSymbol = String(symbol || '').trim()
|
||||
if (!rawSymbol) return []
|
||||
const yahooSymbol = getYahooProbeSymbol(rawSymbol)
|
||||
const cfg = getYahooProbeConfig()
|
||||
const trace = Array.isArray(options.trace) ? options.trace : null
|
||||
if (!cfg.enabled) {
|
||||
pushYahooTrace(trace, 'yahoo.chart.disabled', { symbol: rawSymbol, reason: 'probe_disabled' })
|
||||
return []
|
||||
}
|
||||
const range = String(options.range || '1y').trim() || '1y'
|
||||
const interval = String(options.interval || '1d').trim() || '1d'
|
||||
const includePrePost = options.includePrePost === true || String(options.includePrePost || '') === '1'
|
||||
const cacheKey = `${rawSymbol}|${range}|${interval}|${includePrePost ? 1 : 0}`
|
||||
const cached = getYahooProbeCache(yahooProbeState.chartCache, cacheKey)
|
||||
if (cached) {
|
||||
pushYahooTrace(trace, 'yahoo.chart.cache_hit', { symbol: rawSymbol, range, interval })
|
||||
return cached
|
||||
}
|
||||
|
||||
let promise = yahooProbeState.chartInflight.get(cacheKey)
|
||||
if (!promise) {
|
||||
promise = (async () => {
|
||||
await throttleYahooProbe('chart', cfg, trace)
|
||||
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.chart_endpoint}`
|
||||
// #region debug-point B:yahoo-chart-request
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'B', location: 'marketsClient.js:fetchYahooChart:request', msg: '[DEBUG] yahoo chart request', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, includePrePost, url }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
logJSON('markets.http.get', { source: 'yahoo_probe_chart', url, symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval }, 'markets')
|
||||
const resp = await axios.get(url, {
|
||||
timeout: cfg.request_timeout_ms,
|
||||
validateStatus: () => true,
|
||||
params: {
|
||||
token: cfg.token,
|
||||
type: 'chart',
|
||||
symbol: yahooSymbol,
|
||||
range,
|
||||
interval,
|
||||
includePrePost: includePrePost ? '1' : '0'
|
||||
}
|
||||
})
|
||||
if (resp && resp.status === 429) {
|
||||
pushYahooTrace(trace, 'yahoo.chart.http_429', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval })
|
||||
logJSON('markets.http.status', { source: 'yahoo_probe_chart', url, status: 429, symbol: rawSymbol, yahoo_symbol: yahooSymbol, fallback: 'finnhub_probe_candle' }, 'markets')
|
||||
return requestFinnhubCandle({ original: rawSymbol, yahoo: yahooSymbol }, { range, interval, trace }, cfg)
|
||||
}
|
||||
if (!resp || resp.status >= 400) throw new Error(`yahoo_probe_chart_http_${resp && resp.status ? resp.status : 'ERR'}`)
|
||||
if (!resp.data) throw new Error('yahoo_probe_chart_invalid_response')
|
||||
const rows = parseYahooChartPayload(resp.data, { original: rawSymbol, yahoo: yahooSymbol })
|
||||
// #region debug-point C:yahoo-chart-response
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'C', location: 'marketsClient.js:fetchYahooChart:response', msg: '[DEBUG] yahoo chart response', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, status: resp.status, count: rows.length, sample_trade_date: (((rows || [])[0]) || {}).trade_date || '' }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
pushYahooTrace(trace, 'yahoo.chart.remote_ok', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, count: rows.length })
|
||||
return rows
|
||||
})()
|
||||
yahooProbeState.chartInflight.set(cacheKey, promise)
|
||||
promise
|
||||
.then(
|
||||
() => yahooProbeState.chartInflight.delete(cacheKey),
|
||||
() => yahooProbeState.chartInflight.delete(cacheKey)
|
||||
)
|
||||
.catch(() => {})
|
||||
} else {
|
||||
pushYahooTrace(trace, 'yahoo.chart.inflight_reuse', { symbol: rawSymbol, range, interval })
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await promise
|
||||
setYahooProbeCache(yahooProbeState.chartCache, cacheKey, rows, cfg.chart_ttl_seconds)
|
||||
return rows
|
||||
} catch (e) {
|
||||
pushYahooTrace(trace, 'yahoo.chart.remote_error', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e.message || e) })
|
||||
// #region debug-point C:yahoo-chart-error
|
||||
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'C', location: 'marketsClient.js:fetchYahooChart:error', msg: '[DEBUG] yahoo chart error', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e && e.message ? e.message : e) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
||||
// #endregion
|
||||
logJSON('markets.http.error', { source: 'yahoo_probe_chart', symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e.message || e) }, 'markets')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
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: Price, Change%, ?, ?, ?, ?, Time, PrevClose, Open, ... , Name
|
||||
// e.g. 5098.95,3.32,5097.20,5097.90,5109.30,4930.40,15:24:08,4935.00,4966.10,0,4,2,2026-02-04,纽约黄金
|
||||
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 last_trade_date = /^\d{4}-\d{2}-\d{2}$/.test(String(parts[12] || '')) ? String(parts[12]) : ''
|
||||
return { symbol, name, price, prevClose, change, change_percent: pct, last_trade_date }
|
||||
}
|
||||
|
||||
const name = parts[1] || symbol
|
||||
const price = parseFloat(parts[3] || '0')
|
||||
const prevClose = parseFloat(parts[4] || '0')
|
||||
const change = price - prevClose
|
||||
const pct = prevClose ? (change / prevClose) * 100 : 0
|
||||
return { symbol, name, price, prevClose, change, change_percent: pct }
|
||||
}
|
||||
|
||||
const fetchTencent = async symbols => {
|
||||
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
|
||||
logJSON('markets.http.get', { source: 'tencent', url, symbols }, 'markets')
|
||||
try {
|
||||
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
|
||||
if (!res || res.status >= 400) { logJSON('markets.http.status', { source: 'tencent', url, status: res && res.status }, 'markets'); 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' }) }
|
||||
logJSON('markets.http.parse', { source: 'tencent', url, count: out.length }, 'markets')
|
||||
return out
|
||||
} catch (e) {
|
||||
logJSON('markets.http.error', { source: 'tencent', url, error: String(e.message || e) }, 'markets')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEastmoneyComexGold = async () => {
|
||||
const url = 'https://quote.eastmoney.com/globalfuture/GC00Y.html'
|
||||
logJSON('markets.http.get', { source: 'eastmoney', url }, 'markets')
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true })
|
||||
if (!res || res.status >= 400 || !res.data) {
|
||||
logJSON('markets.http.status', { source: 'eastmoney', url, status: res && res.status }, 'markets')
|
||||
return null
|
||||
}
|
||||
const html = String(res.data || '')
|
||||
let m = html.match(/最新[::]\s*([0-9]+(?:\.[0-9]+)?)/)
|
||||
if (!m) m = html.match(/最新价[::]\s*([0-9]+(?:\.[0-9]+)?)/)
|
||||
const priceStr = m ? m[1] : null
|
||||
if (!priceStr) {
|
||||
logJSON('markets.http.parse', { source: 'eastmoney', url, matched: false }, 'markets')
|
||||
return null
|
||||
}
|
||||
const price = parseFloat(priceStr)
|
||||
if (!Number.isFinite(price)) return null
|
||||
return { symbol: 'hf_GC', name: 'COMEX黄金', price, prevClose: 0, change: 0, change_percent: 0, source: 'eastmoney' }
|
||||
} catch (e) {
|
||||
logJSON('markets.http.error', { source: 'eastmoney', url, error: String(e.message || e) }, 'markets')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchSina, fetchYahoo, fetchYahooChart, fetchTencent, fetchEastmoneyComexGold, getYahooProbeConfig }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'private_clipboard')
|
||||
const DB_PATH = process.env.PRIVATE_CLIPBOARD_DB_PATH || path.join(DATA_DIR, 'private_clipboard.db')
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
let db = null
|
||||
const buildFolderCode = () => `fd_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`
|
||||
const toPositiveInt = (value, fallback) => {
|
||||
const n = Number.parseInt(String(value == null ? '' : value), 10)
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||
return n
|
||||
}
|
||||
const ensureSchema = () => {
|
||||
if (!db) return
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clipboard_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
text_content TEXT,
|
||||
file_name TEXT,
|
||||
file_mime TEXT,
|
||||
file_size INTEGER,
|
||||
file_path TEXT,
|
||||
group_id INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS clipboard_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipboard_folders_sort_order ON clipboard_folders(sort_order, id);
|
||||
`)
|
||||
const cols = db.prepare('PRAGMA table_info(clipboard_items)').all()
|
||||
const hasGroup = cols.some(c => c.name === 'group_id')
|
||||
if (!hasGroup) db.exec('ALTER TABLE clipboard_items ADD COLUMN group_id INTEGER')
|
||||
const hasFavoriteFolderCode = cols.some(c => c.name === 'favorite_folder_code')
|
||||
if (!hasFavoriteFolderCode) db.exec('ALTER TABLE clipboard_items ADD COLUMN favorite_folder_code TEXT')
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_clipboard_items_favorite_folder_code ON clipboard_items(favorite_folder_code)')
|
||||
}
|
||||
|
||||
const getDb = () => {
|
||||
if (db) {
|
||||
ensureSchema()
|
||||
return db
|
||||
}
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.pragma('busy_timeout = 4000')
|
||||
ensureSchema()
|
||||
return db
|
||||
}
|
||||
|
||||
const normalizeName = (name) => {
|
||||
const base = path.basename(String(name || '').replace(/\0/g, ''))
|
||||
return base || 'file'
|
||||
}
|
||||
|
||||
const extFromMime = (mime) => {
|
||||
const m = String(mime || '').toLowerCase()
|
||||
if (m === 'image/png') return '.png'
|
||||
if (m === 'image/jpeg') return '.jpg'
|
||||
if (m === 'image/jpg') return '.jpg'
|
||||
if (m === 'image/webp') return '.webp'
|
||||
if (m === 'image/gif') return '.gif'
|
||||
if (m === 'image/bmp') return '.bmp'
|
||||
if (m === 'image/svg+xml') return '.svg'
|
||||
return ''
|
||||
}
|
||||
|
||||
const saveFile = (raw, name, mime) => {
|
||||
const safe = normalizeName(name)
|
||||
let ext = path.extname(safe)
|
||||
if (!ext) ext = extFromMime(mime)
|
||||
const stamp = Date.now()
|
||||
const rand = crypto.randomBytes(6).toString('hex')
|
||||
const fileName = `${stamp}-${rand}${ext}`
|
||||
const rel = path.join('uploads', 'private_clipboard', fileName)
|
||||
const abs = path.join(process.cwd(), rel)
|
||||
fs.writeFileSync(abs, raw)
|
||||
const size = raw.length
|
||||
return { rel, fileName: safe, size }
|
||||
}
|
||||
|
||||
const formatRow = (row) => {
|
||||
const filePath = row.file_path ? String(row.file_path) : ''
|
||||
const url = filePath ? '/' + filePath.replace(/^\/+/, '') : ''
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
text_content: row.text_content || '',
|
||||
file_name: row.file_name || '',
|
||||
file_mime: row.file_mime || '',
|
||||
file_size: row.file_size || 0,
|
||||
file_url: url,
|
||||
favorite_folder_code: row.favorite_folder_code || null,
|
||||
favorite_folder_name: row.favorite_folder_name || '',
|
||||
created_at: row.created_at
|
||||
}
|
||||
}
|
||||
|
||||
const createRouter = () => {
|
||||
const router = express.Router()
|
||||
|
||||
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')
|
||||
res.set('Surrogate-Control', 'no-store')
|
||||
} catch {}
|
||||
next()
|
||||
})
|
||||
|
||||
router.get('/list', (req, res) => {
|
||||
try {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT i.*, f.name AS favorite_folder_name
|
||||
FROM clipboard_items i
|
||||
LEFT JOIN clipboard_folders f ON f.code = i.favorite_folder_code
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
`).all()
|
||||
const groups = new Map()
|
||||
rows.forEach(row => {
|
||||
const gid = row.group_id ? Number(row.group_id) : null
|
||||
const key = gid ? `g:${gid}` : `r:${row.id}`
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = {
|
||||
id: gid || row.id,
|
||||
type: row.type,
|
||||
text_content: row.text_content || '',
|
||||
created_at: row.created_at,
|
||||
items: [],
|
||||
group_types: [],
|
||||
favorite_folder_code: row.favorite_folder_code || null,
|
||||
favorite_folder_name: row.favorite_folder_name || ''
|
||||
}
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.group_types.push(row.type)
|
||||
if (row.favorite_folder_code && !group.favorite_folder_code) {
|
||||
group.favorite_folder_code = row.favorite_folder_code
|
||||
group.favorite_folder_name = row.favorite_folder_name || ''
|
||||
}
|
||||
if (row.type === 'text') {
|
||||
if (!group.text_content) group.text_content = row.text_content || ''
|
||||
} else {
|
||||
group.items.push(formatRow(row))
|
||||
}
|
||||
})
|
||||
const items = Array.from(groups.values()).map(group => {
|
||||
const unique = Array.from(new Set(group.group_types.filter(Boolean)))
|
||||
if (unique.length === 0 && group.text_content) unique.push('text')
|
||||
let type = group.type
|
||||
if (group.items.length) {
|
||||
if (unique.length === 1) type = unique[0]
|
||||
else type = 'mixed'
|
||||
} else {
|
||||
type = 'text'
|
||||
}
|
||||
return {
|
||||
id: group.id,
|
||||
type,
|
||||
text_content: group.text_content,
|
||||
created_at: group.created_at,
|
||||
items: group.items,
|
||||
group_types: unique,
|
||||
favorite_folder_code: group.favorite_folder_code || null,
|
||||
favorite_folder_name: group.favorite_folder_name || '',
|
||||
is_favorite: !!group.favorite_folder_code
|
||||
}
|
||||
})
|
||||
res.json({ ok: true, items })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/folders', (req, res) => {
|
||||
try {
|
||||
const folders = getDb().prepare(`
|
||||
SELECT code, name, sort_order, created_at
|
||||
FROM clipboard_folders
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`).all()
|
||||
const rawCounts = getDb().prepare(`
|
||||
SELECT favorite_folder_code AS code, COUNT(DISTINCT CASE WHEN group_id IS NOT NULL THEN 'g:' || group_id ELSE 'r:' || id END) AS cnt
|
||||
FROM clipboard_items
|
||||
WHERE favorite_folder_code IS NOT NULL
|
||||
GROUP BY favorite_folder_code
|
||||
`).all()
|
||||
const countMap = new Map(rawCounts.map(r => [String(r.code || ''), Number(r.cnt || 0)]))
|
||||
const list = folders.map(folder => ({
|
||||
code: folder.code,
|
||||
name: folder.name,
|
||||
sort_order: Number(folder.sort_order || 1),
|
||||
created_at: Number(folder.created_at || Date.now()),
|
||||
favorite_count: countMap.get(String(folder.code || '')) || 0
|
||||
}))
|
||||
res.json({ ok: true, folders: list })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/folders', (req, res) => {
|
||||
try {
|
||||
const name = String((req.body && req.body.name) || '').trim()
|
||||
if (!name) return res.status(400).json({ ok: false, error: 'invalid_folder_name' })
|
||||
const maxRow = getDb().prepare('SELECT MAX(sort_order) AS max_sort FROM clipboard_folders').get()
|
||||
const defaultSort = Number(maxRow && maxRow.max_sort ? maxRow.max_sort : 0) + 1
|
||||
const sortOrder = toPositiveInt(req.body && req.body.sort_order, defaultSort)
|
||||
const now = Date.now()
|
||||
const code = buildFolderCode()
|
||||
getDb().prepare('INSERT INTO clipboard_folders (code, name, sort_order, created_at) VALUES (?, ?, ?, ?)').run(code, name, sortOrder, now)
|
||||
res.json({ ok: true, folder: { code, name, sort_order: sortOrder, created_at: now, favorite_count: 0 } })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/folders/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
|
||||
const folder = getDb().prepare('SELECT code, name, sort_order, created_at FROM clipboard_folders WHERE code = ?').get(code)
|
||||
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
|
||||
const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, 'name')
|
||||
const hasSort = Object.prototype.hasOwnProperty.call(req.body || {}, 'sort_order')
|
||||
if (!hasName && !hasSort) return res.status(400).json({ ok: false, error: 'nothing_to_update' })
|
||||
const nextName = hasName ? String(req.body.name || '').trim() : String(folder.name || '')
|
||||
if (!nextName) return res.status(400).json({ ok: false, error: 'invalid_folder_name' })
|
||||
const nextSort = hasSort ? toPositiveInt(req.body.sort_order, -1) : Number(folder.sort_order || 1)
|
||||
if (!Number.isFinite(nextSort) || nextSort <= 0) return res.status(400).json({ ok: false, error: 'invalid_sort_order' })
|
||||
getDb().prepare('UPDATE clipboard_folders SET name = ?, sort_order = ? WHERE code = ?').run(nextName, nextSort, code)
|
||||
res.json({ ok: true, folder: { code, name: nextName, sort_order: nextSort, created_at: Number(folder.created_at || Date.now()) } })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/folders/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
|
||||
const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(code)
|
||||
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
|
||||
const used = getDb().prepare('SELECT COUNT(1) AS cnt FROM clipboard_items WHERE favorite_folder_code = ?').get(code)
|
||||
if (Number(used && used.cnt ? used.cnt : 0) > 0) {
|
||||
return res.status(409).json({ ok: false, error: 'folder_not_empty' })
|
||||
}
|
||||
getDb().prepare('DELETE FROM clipboard_folders WHERE code = ?').run(code)
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/favorite', (req, res) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.body && req.body.id), 10)
|
||||
const folderCode = String((req.body && req.body.folder_code) || '').trim()
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||||
if (!folderCode) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
|
||||
const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(folderCode)
|
||||
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
|
||||
let changed = 0
|
||||
if (id < 0) {
|
||||
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ? WHERE group_id = ?').run(folderCode, id)
|
||||
changed = Number(info.changes || 0)
|
||||
} else {
|
||||
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ? WHERE id = ?').run(folderCode, id)
|
||||
changed = Number(info.changes || 0)
|
||||
}
|
||||
if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/unfavorite', (req, res) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.body && req.body.id), 10)
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||||
let changed = 0
|
||||
if (id < 0) {
|
||||
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL WHERE group_id = ?').run(id)
|
||||
changed = Number(info.changes || 0)
|
||||
} else {
|
||||
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL WHERE id = ?').run(id)
|
||||
changed = Number(info.changes || 0)
|
||||
}
|
||||
if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/text', (req, res) => {
|
||||
try {
|
||||
const text = String((req.body && req.body.text) || '').trim()
|
||||
if (!text) return res.status(400).json({ ok: false, error: 'empty_text' })
|
||||
const now = Date.now()
|
||||
const info = getDb().prepare('INSERT INTO clipboard_items (type, text_content, created_at) VALUES (?, ?, ?)').run('text', text, now)
|
||||
res.json({ ok: true, id: info.lastInsertRowid })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/upload', express.raw({ type: '*/*', limit: '200mb' }), (req, res) => {
|
||||
try {
|
||||
const raw = req.body
|
||||
if (!raw || !raw.length) return res.status(400).json({ ok: false, error: 'empty_file' })
|
||||
const name = String(req.query.filename || req.headers['x-file-name'] || '')
|
||||
const mime = String(req.query.mime || req.headers['x-file-type'] || req.headers['content-type'] || '')
|
||||
const gid = parseInt(String(req.query.group_id || ''), 10)
|
||||
const groupId = Number.isFinite(gid) ? gid : null
|
||||
const { rel, fileName, size } = saveFile(raw, name, mime)
|
||||
const now = Date.now()
|
||||
const type = String(mime || '').toLowerCase().startsWith('image/') ? 'image' : 'file'
|
||||
const info = getDb().prepare('INSERT INTO clipboard_items (type, file_name, file_mime, file_size, file_path, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
|
||||
type,
|
||||
fileName,
|
||||
mime,
|
||||
size,
|
||||
rel,
|
||||
groupId,
|
||||
now
|
||||
)
|
||||
res.json({ ok: true, id: info.lastInsertRowid, path: '/' + rel.replace(/^\/+/, '') })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10)
|
||||
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||||
if (id < 0) {
|
||||
const rows = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id)
|
||||
if (!rows.length) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
const hasFavorite = rows.some(row => row && row.favorite_folder_code)
|
||||
if (hasFavorite) return res.status(409).json({ ok: false, error: 'item_favorited' })
|
||||
getDb().prepare('DELETE FROM clipboard_items WHERE group_id = ?').run(id)
|
||||
rows.forEach(row => {
|
||||
if (row && row.file_path) {
|
||||
const rel = String(row.file_path || '').replace(/^\/+/, '')
|
||||
const abs = path.join(process.cwd(), rel)
|
||||
if (fs.existsSync(abs)) fs.unlinkSync(abs)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const row = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||||
if (row.favorite_folder_code) return res.status(409).json({ ok: false, error: 'item_favorited' })
|
||||
getDb().prepare('DELETE FROM clipboard_items WHERE id = ?').run(id)
|
||||
if (row && row.file_path) {
|
||||
const rel = String(row.file_path || '').replace(/^\/+/, '')
|
||||
const abs = path.join(process.cwd(), rel)
|
||||
if (fs.existsSync(abs)) fs.unlinkSync(abs)
|
||||
}
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
module.exports = { createRouter }
|
||||
+1052
File diff suppressed because it is too large
Load Diff
@@ -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, 'rational_lock.db')
|
||||
const db = new Database(dbPath)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rl_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 rl_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rl_user_data (
|
||||
username TEXT PRIMARY KEY,
|
||||
items_json TEXT NOT NULL DEFAULT '[]',
|
||||
applications_json TEXT NOT NULL DEFAULT '[]',
|
||||
draft_json TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rl_sessions_user ON rl_sessions(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_rl_sessions_exp ON rl_sessions(expires_at);
|
||||
`)
|
||||
|
||||
const now = () => Date.now()
|
||||
const SESSION_MAX_AGE = 1 * 24 * 3600 * 1000
|
||||
const COOKIE_NAME = 'rational_lock_sid'
|
||||
const DEFAULT_USER = { username: 'admin_rl', 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 rl_users WHERE username = ?').get(DEFAULT_USER.username)
|
||||
if (!row) {
|
||||
db.prepare('INSERT INTO rl_users (username, password, created_at) VALUES (?, ?, ?)').run(DEFAULT_USER.username, DEFAULT_USER.password, now())
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupSessions = () => {
|
||||
db.prepare('DELETE FROM rl_sessions WHERE expires_at <= ?').run(now())
|
||||
}
|
||||
|
||||
const readDataOf = username => {
|
||||
const row = db.prepare('SELECT items_json, applications_json, draft_json FROM rl_user_data WHERE username = ?').get(username)
|
||||
if (!row) return { db: { items: [], applications: [] }, draft: null }
|
||||
let items = []
|
||||
let applications = []
|
||||
let draft = null
|
||||
try { items = JSON.parse(row.items_json || '[]') } catch {}
|
||||
try { applications = JSON.parse(row.applications_json || '[]') } catch {}
|
||||
try { draft = row.draft_json ? JSON.parse(row.draft_json) : null } catch {}
|
||||
return {
|
||||
db: {
|
||||
items: Array.isArray(items) ? items : [],
|
||||
applications: Array.isArray(applications) ? applications : []
|
||||
},
|
||||
draft: draft && typeof draft === 'object' ? draft : null
|
||||
}
|
||||
}
|
||||
|
||||
const writeDataOf = (username, data) => {
|
||||
const dbData = data && data.db && typeof data.db === 'object' ? data.db : {}
|
||||
const items = Array.isArray(dbData.items) ? dbData.items : []
|
||||
const applications = Array.isArray(dbData.applications) ? dbData.applications : []
|
||||
const draft = data && data.draft && typeof data.draft === 'object' ? data.draft : null
|
||||
db.prepare(`
|
||||
INSERT INTO rl_user_data (username, items_json, applications_json, draft_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
items_json = excluded.items_json,
|
||||
applications_json = excluded.applications_json,
|
||||
draft_json = excluded.draft_json,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
username,
|
||||
JSON.stringify(items),
|
||||
JSON.stringify(applications),
|
||||
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 rl_sessions WHERE token = ?').get(token)
|
||||
if (!row) return ''
|
||||
if (Number(row.expires_at) <= now()) {
|
||||
db.prepare('DELETE FROM rl_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.rlUser = username
|
||||
next()
|
||||
}
|
||||
|
||||
const bindRoutes = app => {
|
||||
ensureDefaultUser()
|
||||
|
||||
app.get('/api/rational_lock/health', (req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/rational_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.RL_USERNAME || ''
|
||||
const p = creds.RL_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/rational_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 rl_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 rl_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/rational_lock/auth/logout', (req, res) => {
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
const token = String(cookies[COOKIE_NAME] || '')
|
||||
if (token) db.prepare('DELETE FROM rl_sessions WHERE token = ?').run(token)
|
||||
res.cookie(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0 })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/rational_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/rational_lock/data', requireAuth, (req, res) => {
|
||||
const payload = readDataOf(req.rlUser)
|
||||
res.json({ ok: true, ...payload })
|
||||
})
|
||||
|
||||
app.put('/api/rational_lock/data', requireAuth, (req, res) => {
|
||||
writeDataOf(req.rlUser, req.body || {})
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { bindRoutes }
|
||||
@@ -0,0 +1,300 @@
|
||||
const express = require('express')
|
||||
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, 'rebalance.db')
|
||||
const db = new Database(dbPath)
|
||||
|
||||
// Initialize Tables
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
position_json TEXT NOT NULL,
|
||||
plan_json TEXT NOT NULL,
|
||||
asset_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(start_date, end_date)
|
||||
);`)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS excluded_stocks (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT
|
||||
);`)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS fund_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(start_date, end_date)
|
||||
);`)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS fund_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
currency TEXT,
|
||||
balance REAL,
|
||||
available REAL,
|
||||
reference_value REAL,
|
||||
asset_value REAL,
|
||||
profit REAL,
|
||||
day_profit REAL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(start_date, end_date)
|
||||
);`)
|
||||
|
||||
// Auth Helpers (Duplicated from index.js to avoid refactoring)
|
||||
const readCookieCfg = () => {
|
||||
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'rebalance', 'assets', 'auth_config.json')
|
||||
let name = 'rebalance_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') {
|
||||
res.cookie(name, '1', { httpOnly: true, sameSite: 'lax', maxAge })
|
||||
return next()
|
||||
}
|
||||
} catch {}
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
|
||||
const bindRoutes = (app) => {
|
||||
// Rebalance API Routes
|
||||
// Note: These routes are mounted at root level by bindRoutes to match original paths /api/...
|
||||
// OR we can prefix them. The original app used /api/...
|
||||
// Since Toolbox uses /api for other things, we need to be careful.
|
||||
// Rebalance specific routes:
|
||||
// /api/weekly-data
|
||||
// /api/fund-shares
|
||||
// /api/settings/excluded-stocks
|
||||
|
||||
// Auth endpoints for Rebalance frontend compatibility
|
||||
app.get('/auth/handshake', (req, res) => {
|
||||
try {
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
const { name } = readCookieCfg()
|
||||
if (cookies[name] === '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) })
|
||||
}
|
||||
})
|
||||
|
||||
// We rely on Toolbox's main auth mechanism for setting the cookie.
|
||||
// But if the user hits this endpoint, we can just check if they are already auth'd.
|
||||
app.get('/auth/token', (req, res) => {
|
||||
// This is just a dummy to satisfy Rebalance frontend if it calls it.
|
||||
// In simplified mode, we expect the user to be logged in via Toolbox.
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
const { name } = readCookieCfg()
|
||||
if (cookies[name] === '1') return res.json({ ok: true })
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
})
|
||||
|
||||
app.get('/api/weekly-data', checkAuth, (req, res) => {
|
||||
const { start, end } = req.query
|
||||
if (!start || !end) return res.status(400).json({ error: 'missing params' })
|
||||
try {
|
||||
const row = db.prepare('SELECT position_json, plan_json, asset_json FROM weeks WHERE start_date=? AND end_date=?').get(start, end)
|
||||
if (!row) return res.status(404).json({ error: 'not found' })
|
||||
res.json({
|
||||
positionData: JSON.parse(row.position_json),
|
||||
planData: JSON.parse(row.plan_json),
|
||||
assetData: JSON.parse(row.asset_json)
|
||||
})
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/weekly-data', checkAuth, (req, res) => {
|
||||
const { weekStart, weekEnd, positionData, planData, assetData, overwrite } = req.body
|
||||
if (!weekStart || !weekEnd || !Array.isArray(positionData) || !Array.isArray(planData) || !Array.isArray(assetData)) {
|
||||
return res.status(400).json({ error: 'invalid payload' })
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const exists = db.prepare('SELECT id FROM weeks WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
|
||||
if (exists && !overwrite) return res.status(409).json({ error: 'exists' })
|
||||
if (exists && overwrite) {
|
||||
db.prepare('UPDATE weeks SET position_json=?, plan_json=?, asset_json=?, updated_at=? WHERE id=?')
|
||||
.run(JSON.stringify(positionData), JSON.stringify(planData), JSON.stringify(assetData), now, exists.id)
|
||||
return res.json({ status: 'updated' })
|
||||
}
|
||||
db.prepare('INSERT INTO weeks (start_date, end_date, position_json, plan_json, asset_json, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(weekStart, weekEnd, JSON.stringify(positionData), JSON.stringify(planData), JSON.stringify(assetData), now, now)
|
||||
res.json({ status: 'saved' })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/fund-shares', checkAuth, (req, res) => {
|
||||
const { start, end } = req.query
|
||||
if (!start || !end) return res.status(400).json({ error: 'missing params' })
|
||||
try {
|
||||
const row = db.prepare('SELECT data_json FROM fund_shares WHERE start_date=? AND end_date=?').get(start, end)
|
||||
if (!row) return res.status(404).json({ error: 'not found' })
|
||||
|
||||
let payload = null
|
||||
try { payload = JSON.parse(row.data_json) } catch (e) { payload = null }
|
||||
let data = []
|
||||
let summaryJson = null
|
||||
if (Array.isArray(payload)) data = payload
|
||||
else if (payload && typeof payload === 'object') { data = payload.data || []; summaryJson = payload.summary || null }
|
||||
|
||||
let summary = null
|
||||
const srow = db.prepare('SELECT currency, balance, available, reference_value, asset_value, profit, day_profit FROM fund_summaries WHERE start_date=? AND end_date=?').get(start, end)
|
||||
if (srow) {
|
||||
summary = {
|
||||
币种: srow.currency || null,
|
||||
余额: typeof srow.balance === 'number' ? srow.balance : null,
|
||||
可用: typeof srow.available === 'number' ? srow.available : null,
|
||||
参考市值: typeof srow.reference_value === 'number' ? srow.reference_value : null,
|
||||
资产: typeof srow.asset_value === 'number' ? srow.asset_value : null,
|
||||
盈亏: typeof srow.profit === 'number' ? srow.profit : null,
|
||||
当日盈亏: typeof srow.day_profit === 'number' ? srow.day_profit : null
|
||||
}
|
||||
}
|
||||
if (!summary) summary = summaryJson || null
|
||||
res.json({ data, summary })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/fund-shares', checkAuth, (req, res) => {
|
||||
const { weekStart, weekEnd, data, summary, overwrite } = req.body
|
||||
if (!weekStart || !weekEnd || !Array.isArray(data)) {
|
||||
return res.status(400).json({ error: 'invalid payload' })
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const exists = db.prepare('SELECT id FROM fund_shares WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
|
||||
if (exists && !overwrite) return res.status(409).json({ error: 'exists' })
|
||||
|
||||
if (exists && overwrite) {
|
||||
db.prepare('UPDATE fund_shares SET data_json=?, updated_at=? WHERE id=?')
|
||||
.run(JSON.stringify(data), now, exists.id)
|
||||
|
||||
// upsert summary
|
||||
if (summary && typeof summary === 'object') {
|
||||
const sexists = db.prepare('SELECT id FROM fund_summaries WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
|
||||
const vals = [summary.币种 || null, summary.余额 ?? null, summary.可用 ?? null, summary.参考市值 ?? null, summary.资产 ?? null, summary.盈亏 ?? null, summary.当日盈亏 ?? null, now]
|
||||
if (sexists) {
|
||||
db.prepare('UPDATE fund_summaries SET currency=?, balance=?, available=?, reference_value=?, asset_value=?, profit=?, day_profit=?, updated_at=? WHERE id=?')
|
||||
.run(...vals, sexists.id)
|
||||
} else {
|
||||
db.prepare('INSERT INTO fund_summaries (start_date, end_date, currency, balance, available, reference_value, asset_value, profit, day_profit, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(weekStart, weekEnd, ...vals, now)
|
||||
}
|
||||
}
|
||||
return res.json({ status: 'updated' })
|
||||
}
|
||||
|
||||
db.prepare('INSERT INTO fund_shares (start_date, end_date, data_json, created_at, updated_at) VALUES (?,?,?,?,?)')
|
||||
.run(weekStart, weekEnd, JSON.stringify(data), now, now)
|
||||
|
||||
if (summary && typeof summary === 'object') {
|
||||
db.prepare('INSERT INTO fund_summaries (start_date, end_date, currency, balance, available, reference_value, asset_value, profit, day_profit, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(weekStart, weekEnd, summary.币种 || null, summary.余额 ?? null, summary.可用 ?? null, summary.参考市值 ?? null, summary.资产 ?? null, summary.盈亏 ?? null, summary.当日盈亏 ?? null, now, now)
|
||||
}
|
||||
res.json({ status: 'saved' })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/fund-shares', checkAuth, (req, res) => {
|
||||
const { start, end } = req.query
|
||||
let password = null
|
||||
try { password = (req.body && req.body.password) || null } catch (e) {}
|
||||
if (!start || !end) return res.status(400).json({ error: 'missing params' })
|
||||
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
|
||||
try {
|
||||
db.prepare('DELETE FROM fund_shares WHERE start_date=? AND end_date=?').run(start, end)
|
||||
res.json({ status: 'deleted' })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/fund-shares/delete', checkAuth, (req, res) => {
|
||||
const { start, end, password } = req.body || {}
|
||||
if (!start || !end) return res.status(400).json({ error: 'missing params' })
|
||||
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
|
||||
try {
|
||||
db.prepare('DELETE FROM fund_shares WHERE start_date=? AND end_date=?').run(start, end)
|
||||
res.json({ status: 'deleted' })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/fund-shares/clear-all', checkAuth, (req, res) => {
|
||||
const { password } = req.body || {}
|
||||
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
|
||||
try {
|
||||
db.exec('DELETE FROM fund_shares')
|
||||
db.exec('DELETE FROM fund_summaries')
|
||||
res.json({ status: 'all_deleted' })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/settings/excluded-stocks', checkAuth, (req, res) => {
|
||||
try {
|
||||
const rows = db.prepare('SELECT code FROM excluded_stocks').all()
|
||||
res.json({ codes: rows.map((r) => r.code) })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/settings/excluded-stocks', checkAuth, (req, res) => {
|
||||
const { codes } = req.body
|
||||
if (!Array.isArray(codes)) return res.status(400).json({ error: 'invalid payload' })
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('DELETE FROM excluded_stocks')
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO excluded_stocks (code) VALUES (?)')
|
||||
codes.forEach((code) => {
|
||||
if (typeof code === 'string' && code.trim()) {
|
||||
stmt.run(code.trim())
|
||||
}
|
||||
})
|
||||
})()
|
||||
res.json({ status: 'saved', codes })
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'db error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { bindRoutes }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const axios = require('axios')
|
||||
const Database = require('better-sqlite3')
|
||||
const { db } = require('./db')
|
||||
const { logJSON } = require('./logger')
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||||
const bridgeConfigPath = path.join(process.cwd(), 'config', 'security_calendar_calendar_bridge.json')
|
||||
|
||||
const bridgeDbPath = path.join(dataDir, 'security_calendar_calendar_bridge.db')
|
||||
const bridgeDb = new Database(bridgeDbPath)
|
||||
|
||||
bridgeDb.exec(`CREATE TABLE IF NOT EXISTS bridge_sync (
|
||||
source_key TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
apply_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
market TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
synced_at TEXT DEFAULT (datetime('now','+8 hours')),
|
||||
last_error TEXT DEFAULT '',
|
||||
payload_json TEXT DEFAULT ''
|
||||
);`)
|
||||
|
||||
const upsertSyncStmt = bridgeDb.prepare(`INSERT INTO bridge_sync (
|
||||
source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
|
||||
) VALUES (
|
||||
@source_key, @event_id, @apply_date, @stock_code, @stock_name, @market, @status, datetime('now','+8 hours'), @last_error, @payload_json
|
||||
)
|
||||
ON CONFLICT(source_key) DO UPDATE SET
|
||||
event_id = excluded.event_id,
|
||||
apply_date = excluded.apply_date,
|
||||
stock_code = excluded.stock_code,
|
||||
stock_name = excluded.stock_name,
|
||||
market = excluded.market,
|
||||
status = excluded.status,
|
||||
synced_at = datetime('now','+8 hours'),
|
||||
last_error = excluded.last_error,
|
||||
payload_json = excluded.payload_json`)
|
||||
const listSyncRangeStmt = bridgeDb.prepare(`SELECT source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
|
||||
FROM bridge_sync
|
||||
WHERE apply_date >= ? AND apply_date <= ?
|
||||
ORDER BY apply_date ASC, stock_code ASC`)
|
||||
const getSyncByKeyStmt = bridgeDb.prepare(`SELECT source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
|
||||
FROM bridge_sync
|
||||
WHERE source_key = ?`)
|
||||
|
||||
const listIpoRangeStmt = db.prepare(`SELECT apply_date, stock_code, stock_name, market, issue_count_wan, apply_limit_wan, issue_price, pe_ratio, status, source
|
||||
FROM security_calendar_ipos
|
||||
WHERE apply_date >= ? AND apply_date <= ?
|
||||
ORDER BY apply_date ASC, stock_code ASC`)
|
||||
|
||||
let schedulerStarted = false
|
||||
|
||||
const pad2 = n => String(n).padStart(2, '0')
|
||||
const fmtDate = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
|
||||
function sourceKeyOf(applyDate, stockCode) {
|
||||
return `ipo:${String(applyDate || '').trim()}:${String(stockCode || '').trim()}`
|
||||
}
|
||||
|
||||
function eventIdOf(applyDate, stockCode) {
|
||||
return `sec-ipo-${String(applyDate || '').trim()}-${String(stockCode || '').trim()}`
|
||||
}
|
||||
|
||||
function parseBeijingLocalToIso(dateStr, timeStr) {
|
||||
const date = String(dateStr || '').trim()
|
||||
const time = String(timeStr || '09:00').trim()
|
||||
const matched = `${date} ${time}`.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/)
|
||||
if (!matched) return new Date().toISOString()
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
const day = Number(matched[3])
|
||||
const hour = Number(matched[4])
|
||||
const minute = Number(matched[5])
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 8, minute, 0)
|
||||
return new Date(utcMs).toISOString()
|
||||
}
|
||||
|
||||
function calendarEndpoint() {
|
||||
return `${getCalendarApiBases()[0]}/events`
|
||||
}
|
||||
|
||||
function getCalendarApiBases() {
|
||||
const defaults = [
|
||||
'http://127.0.0.1:8976/api/calendar/external',
|
||||
'http://127.0.0.1:8977/api/calendar/external'
|
||||
]
|
||||
try {
|
||||
if (fs.existsSync(bridgeConfigPath)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(bridgeConfigPath, 'utf-8'))
|
||||
const primary = String((cfg && cfg.calendar_api_base) || '').trim()
|
||||
const fallbacks = Array.isArray(cfg && cfg.fallback_api_bases) ? cfg.fallback_api_bases.map(item => String(item || '').trim()).filter(Boolean) : []
|
||||
return Array.from(new Set([primary].concat(fallbacks, defaults).filter(Boolean)))
|
||||
}
|
||||
} catch {}
|
||||
return defaults
|
||||
}
|
||||
|
||||
function buildMarketsCalendarUrl(applyDate) {
|
||||
const month = String(applyDate || '').slice(0, 7)
|
||||
return `/tools/markets?tab=calendar&month=${encodeURIComponent(month)}&date=${encodeURIComponent(String(applyDate || ''))}`
|
||||
}
|
||||
|
||||
function buildPayload(item) {
|
||||
const applyDate = String(item && item.apply_date || '').trim()
|
||||
const stockCode = String(item && item.stock_code || '').trim()
|
||||
const stockName = String(item && item.stock_name || '').trim() || stockCode
|
||||
const market = String(item && item.market || 'A股').trim() || 'A股'
|
||||
if (!applyDate || !stockCode) throw new Error('bad_ipo_identity')
|
||||
const sourceKey = sourceKeyOf(applyDate, stockCode)
|
||||
const eventId = eventIdOf(applyDate, stockCode)
|
||||
const title = `${applyDate} 打新股提醒|${stockName}(${stockCode})|${market}`
|
||||
const detailUrl = buildMarketsCalendarUrl(applyDate)
|
||||
const lines = [
|
||||
title,
|
||||
`来源: 证券日历`,
|
||||
`关键索引ID: ${sourceKey}`,
|
||||
`日历事件ID: ${eventId}`,
|
||||
`申购日期: ${applyDate}`,
|
||||
`股票代码: ${stockCode}`,
|
||||
`股票名称: ${stockName}`,
|
||||
`交易所: ${market}`,
|
||||
`状态: ${String(item && item.status || '待申购').trim() || '待申购'}`,
|
||||
`链接: ${detailUrl}`
|
||||
]
|
||||
const issuePrice = Number(item && item.issue_price)
|
||||
const peRatio = Number(item && item.pe_ratio)
|
||||
const applyLimitWan = Number(item && item.apply_limit_wan)
|
||||
if (Number.isFinite(issuePrice)) lines.push(`发行价: ${issuePrice.toFixed(2)} 元`)
|
||||
if (Number.isFinite(peRatio)) lines.push(`市盈率: ${peRatio.toFixed(2)}`)
|
||||
if (Number.isFinite(applyLimitWan)) lines.push(`顶格申购: ${applyLimitWan.toFixed(2)} 万股`)
|
||||
const startIso = parseBeijingLocalToIso(applyDate, '09:00')
|
||||
const endIso = parseBeijingLocalToIso(applyDate, '15:00')
|
||||
const remindIso = parseBeijingLocalToIso(applyDate, '08:50')
|
||||
return {
|
||||
source_key: sourceKey,
|
||||
event_id: eventId,
|
||||
event_payload: {
|
||||
id: eventId,
|
||||
title,
|
||||
content: lines.join('\n'),
|
||||
start: startIso,
|
||||
end: endIso,
|
||||
remindAt: remindIso,
|
||||
allDay: false,
|
||||
completed: false,
|
||||
deleted: false,
|
||||
recurrence: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postEvent(payload) {
|
||||
let lastError = null
|
||||
for (const base of getCalendarApiBases()) {
|
||||
const url = `${base}/events`
|
||||
try {
|
||||
await axios.post(url, payload, { timeout: 12000, headers: { 'Content-Type': 'application/json' } })
|
||||
return
|
||||
} catch (e) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('calendar_post_failed')
|
||||
}
|
||||
|
||||
async function deleteEvent(eventId) {
|
||||
let lastError = null
|
||||
for (const base of getCalendarApiBases()) {
|
||||
const url = `${base}/events?_method=DELETE&id=${encodeURIComponent(String(eventId || ''))}`
|
||||
try {
|
||||
await axios.post(url, {}, { timeout: 12000, headers: { 'Content-Type': 'application/json' } })
|
||||
return
|
||||
} catch (e) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('calendar_delete_failed')
|
||||
}
|
||||
|
||||
async function upsertOne(item) {
|
||||
const payload = buildPayload(item)
|
||||
try {
|
||||
await postEvent(payload.event_payload)
|
||||
upsertSyncStmt.run({
|
||||
source_key: payload.source_key,
|
||||
event_id: payload.event_id,
|
||||
apply_date: String(item.apply_date || ''),
|
||||
stock_code: String(item.stock_code || ''),
|
||||
stock_name: String(item.stock_name || ''),
|
||||
market: String(item.market || ''),
|
||||
status: 'synced',
|
||||
last_error: '',
|
||||
payload_json: JSON.stringify(payload.event_payload)
|
||||
})
|
||||
return { ok: true, source_key: payload.source_key, event_id: payload.event_id }
|
||||
} catch (e) {
|
||||
upsertSyncStmt.run({
|
||||
source_key: payload.source_key,
|
||||
event_id: payload.event_id,
|
||||
apply_date: String(item.apply_date || ''),
|
||||
stock_code: String(item.stock_code || ''),
|
||||
stock_name: String(item.stock_name || ''),
|
||||
market: String(item.market || ''),
|
||||
status: 'error',
|
||||
last_error: String(e && e.message ? e.message : e),
|
||||
payload_json: JSON.stringify(payload.event_payload)
|
||||
})
|
||||
return { ok: false, source_key: payload.source_key, event_id: payload.event_id, error: String(e && e.message ? e.message : e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOneByBridge(row) {
|
||||
const eventId = String((row && row.event_id) || '')
|
||||
const sourceKey = String((row && row.source_key) || '')
|
||||
if (!eventId || !sourceKey) return { ok: false, error: 'bad_bridge_row' }
|
||||
try {
|
||||
await deleteEvent(eventId)
|
||||
upsertSyncStmt.run({
|
||||
source_key: sourceKey,
|
||||
event_id: eventId,
|
||||
apply_date: String(row.apply_date || ''),
|
||||
stock_code: String(row.stock_code || ''),
|
||||
stock_name: String(row.stock_name || ''),
|
||||
market: String(row.market || ''),
|
||||
status: 'deleted',
|
||||
last_error: '',
|
||||
payload_json: ''
|
||||
})
|
||||
return { ok: true, source_key: sourceKey, event_id: eventId }
|
||||
} catch (e) {
|
||||
upsertSyncStmt.run({
|
||||
source_key: sourceKey,
|
||||
event_id: eventId,
|
||||
apply_date: String(row.apply_date || ''),
|
||||
stock_code: String(row.stock_code || ''),
|
||||
stock_name: String(row.stock_name || ''),
|
||||
market: String(row.market || ''),
|
||||
status: 'error',
|
||||
last_error: String(e && e.message ? e.message : e),
|
||||
payload_json: ''
|
||||
})
|
||||
return { ok: false, source_key: sourceKey, event_id: eventId, error: String(e && e.message ? e.message : e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRange(startDate, endDate) {
|
||||
const sourceRows = listIpoRangeStmt.all(startDate, endDate)
|
||||
const bridgeRows = listSyncRangeStmt.all(startDate, endDate)
|
||||
const sourceMap = new Map(sourceRows.map(item => [sourceKeyOf(item.apply_date, item.stock_code), item]))
|
||||
const bridgeMap = new Map(bridgeRows.map(item => [String(item.source_key || ''), item]))
|
||||
const upsertResults = []
|
||||
const deleteResults = []
|
||||
|
||||
for (const item of sourceRows) {
|
||||
upsertResults.push(await upsertOne(item))
|
||||
}
|
||||
for (const row of bridgeRows) {
|
||||
if (!sourceMap.has(String(row.source_key || '')) && String(row.status || '') !== 'deleted') {
|
||||
deleteResults.push(await removeOneByBridge(row))
|
||||
}
|
||||
}
|
||||
|
||||
const okUpserts = upsertResults.filter(item => item.ok).length
|
||||
const okDeletes = deleteResults.filter(item => item.ok).length
|
||||
const errors = upsertResults.concat(deleteResults).filter(item => !item.ok)
|
||||
const result = {
|
||||
ok: errors.length === 0,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
source_count: sourceRows.length,
|
||||
previous_bridge_count: bridgeMap.size,
|
||||
upsert_count: okUpserts,
|
||||
delete_count: okDeletes,
|
||||
error_count: errors.length,
|
||||
errors
|
||||
}
|
||||
logJSON('security_calendar.calendar_bridge.sync', result, 'markets')
|
||||
return result
|
||||
}
|
||||
|
||||
function getUpcomingRange(daysAhead) {
|
||||
const now = new Date()
|
||||
const start = fmtDate(now)
|
||||
const end = fmtDate(new Date(now.getTime() + Math.max(7, daysAhead) * 24 * 60 * 60 * 1000))
|
||||
return { start_date: start, end_date: end }
|
||||
}
|
||||
|
||||
function startScheduler(securityCalendarModule) {
|
||||
if (schedulerStarted) return
|
||||
schedulerStarted = true
|
||||
const currentPort = Number(process.env.PORT || '8976')
|
||||
if (currentPort !== 8977) {
|
||||
logJSON('security_calendar.calendar_bridge.scheduler.skip', { port: currentPort, recommended_runner_port: 8977 }, 'markets')
|
||||
return
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
const range = getUpcomingRange(45)
|
||||
if (securityCalendarModule && typeof securityCalendarModule.ensureDateRangeData === 'function') {
|
||||
await securityCalendarModule.ensureDateRangeData(range.start_date, range.end_date, { force_refresh: false })
|
||||
}
|
||||
await syncRange(range.start_date, range.end_date)
|
||||
} catch (e) {
|
||||
logJSON('security_calendar.calendar_bridge.scheduler.error', { error: String(e && e.message ? e.message : e) }, 'markets')
|
||||
}
|
||||
}
|
||||
run()
|
||||
setInterval(run, 4 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
function bySourceKey(sourceKey) {
|
||||
return getSyncByKeyStmt.get(String(sourceKey || '').trim()) || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncRange,
|
||||
startScheduler,
|
||||
bySourceKey,
|
||||
getUpcomingRange
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const assert = require('assert')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { initCrypto, sign, verify, enc, dec, hashPassword } = require('./weekly_crypto')
|
||||
const { initDB, getDB } = require('./weekly_db')
|
||||
const core = require('./weekly_core')
|
||||
const integrations = require('./weekly_integrations')
|
||||
|
||||
const TEST_DB = path.join(__dirname, 'test_weekly.db')
|
||||
const TEST_ENC = path.join(__dirname, 'test_weekly.enc')
|
||||
|
||||
// Cleanup
|
||||
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB)
|
||||
if (fs.existsSync(TEST_ENC)) fs.unlinkSync(TEST_ENC)
|
||||
|
||||
console.log('--- Starting Weekly Module Tests ---')
|
||||
|
||||
// 1. Crypto Test
|
||||
console.log('[1/5] Testing Crypto...')
|
||||
initCrypto('testpass', 'testsalt')
|
||||
const payload = { foo: 'bar' }
|
||||
const sig = sign(payload)
|
||||
assert.strictEqual(verify(payload, sig), true, 'Signature verification failed')
|
||||
assert.strictEqual(verify(payload, 'wrong'), false, 'Signature rejection failed')
|
||||
const secret = 'hello world'
|
||||
const e = enc(secret)
|
||||
const d = dec(e.ct, e.iv, e.tag)
|
||||
assert.strictEqual(d, secret, 'Encryption/Decryption failed')
|
||||
console.log(' Crypto OK')
|
||||
|
||||
// 2. DB Init
|
||||
console.log('[2/5] Testing DB Init...')
|
||||
initDB(TEST_DB, TEST_ENC)
|
||||
const db = getDB()
|
||||
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all()
|
||||
const expectedTables = ['users', 'reports', 'events', 'event_reviews', 'attachments', 'versions', 'logs', 'event_external', 'event_relations', 'email_sends']
|
||||
expectedTables.forEach(t => {
|
||||
assert.ok(tables.find(x => x.name === t), `Table ${t} missing`)
|
||||
})
|
||||
console.log(' DB Init OK')
|
||||
|
||||
// 3. Core Logic
|
||||
console.log('[3/5] Testing Core Logic...')
|
||||
// User
|
||||
core.ensureDefaultUser()
|
||||
const loginRes = core.login('admin_wy', process.env.WEEKLY_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || '')
|
||||
assert.strictEqual(loginRes.ok, true, 'Default user login failed')
|
||||
|
||||
// Report
|
||||
const weekId = '2025-W01'
|
||||
const rId = core.ensureReportForWeek(weekId)
|
||||
const report = core.getReportByWeek(weekId)
|
||||
assert.strictEqual(report.week_id, weekId, 'Report creation failed')
|
||||
|
||||
// Event
|
||||
const eId = core.createEvent({
|
||||
report_id: rId,
|
||||
week_id: weekId,
|
||||
title: 'Test Event',
|
||||
content: 'Test Content',
|
||||
status_code: 'doing',
|
||||
priority: 1
|
||||
})
|
||||
const events = core.listEvents(rId)
|
||||
assert.strictEqual(events.length, 1, 'Event listing failed')
|
||||
assert.strictEqual(events[0].title, 'Test Event', 'Event content mismatch')
|
||||
|
||||
// Update Event
|
||||
core.updateEvent({ id: eId, status_code: 'done' })
|
||||
const events2 = core.listEvents(rId)
|
||||
assert.strictEqual(events2[0].status_code, 'done', 'Event update failed')
|
||||
|
||||
// Search
|
||||
// Need to wait for index rebuild? It's async in scheduleMaintain but synchronous in initial load.
|
||||
// core.js rebuildIndex is not exported directly but called inside module?
|
||||
// Actually I exported it.
|
||||
core.rebuildIndex()
|
||||
const searchRes = core.search('Test')
|
||||
console.log('Search Results:', JSON.stringify(searchRes, null, 2))
|
||||
assert.strictEqual(searchRes.filter(x => x.type === 'event').length, 1, 'Search failed')
|
||||
|
||||
console.log(' Core Logic OK')
|
||||
|
||||
// 4. Integrations
|
||||
console.log('[4/5] Testing Integrations...')
|
||||
// Just check if functions exist
|
||||
assert.strictEqual(typeof integrations.pushCalendarByEventId, 'function')
|
||||
assert.strictEqual(typeof integrations.summarizeText, 'function')
|
||||
console.log(' Integrations Interface OK')
|
||||
|
||||
// 5. Cleanup
|
||||
if (db && typeof db.close === 'function') db.close()
|
||||
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB)
|
||||
if (fs.existsSync(TEST_ENC)) fs.unlinkSync(TEST_ENC)
|
||||
|
||||
console.log('--- All Tests Passed ---')
|
||||
@@ -0,0 +1,187 @@
|
||||
const axios = require('axios')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const Database = require('better-sqlite3')
|
||||
const { logJSON } = require('./logger')
|
||||
|
||||
const SH_LAT = 31.2304
|
||||
const SH_LON = 121.4737
|
||||
|
||||
const fetchWeather = async (lat, lon) => {
|
||||
const latitude = Number(lat) || SH_LAT
|
||||
const longitude = Number(lon) || SH_LON
|
||||
try {
|
||||
const urlWeather = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,apparent_temperature,precipitation&hourly=precipitation_probability&daily=temperature_2m_max,temperature_2m_min&timezone=auto`
|
||||
const urlAir = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${latitude}&longitude=${longitude}&hourly=us_aqi,pm2_5,european_aqi&timezone=auto`
|
||||
const [wRes, aRes] = await Promise.all([
|
||||
axios.get(urlWeather, { timeout: 8000 }),
|
||||
axios.get(urlAir, { timeout: 8000 })
|
||||
])
|
||||
const w = wRes.data || {}
|
||||
const a = aRes.data || {}
|
||||
const cur = (w.current || {})
|
||||
let prob = null
|
||||
try {
|
||||
const times = (w.hourly && w.hourly.time) || []
|
||||
const probs = (w.hourly && w.hourly.precipitation_probability) || []
|
||||
const nowIso = new Date().toISOString().slice(0,13)
|
||||
const idx = times.findIndex(t => String(t).slice(0,13) === nowIso)
|
||||
if (idx >= 0) prob = probs[idx]
|
||||
} catch {}
|
||||
const hAqi = (a.hourly || {})
|
||||
let tmin = null, tmax = null
|
||||
try {
|
||||
const dTimes = (w.daily && w.daily.time) || []
|
||||
const dMin = (w.daily && w.daily.temperature_2m_min) || []
|
||||
const dMax = (w.daily && w.daily.temperature_2m_max) || []
|
||||
const today = new Date().toISOString().slice(0,10)
|
||||
const di = dTimes.findIndex(t => String(t).slice(0,10) === today)
|
||||
if (di >= 0) { tmin = dMin[di]; tmax = dMax[di] }
|
||||
} catch {}
|
||||
const info = {
|
||||
source: 'open-meteo',
|
||||
city: '上海',
|
||||
temperature: cur.temperature_2m,
|
||||
apparent_temperature: cur.apparent_temperature,
|
||||
precipitation: cur.precipitation,
|
||||
precipitation_probability: prob,
|
||||
us_aqi: hAqi.us_aqi ? hAqi.us_aqi[0] : undefined,
|
||||
european_aqi: hAqi.european_aqi ? hAqi.european_aqi[0] : undefined,
|
||||
pm2_5: hAqi.pm2_5 ? hAqi.pm2_5[0] : undefined,
|
||||
temperature_min: tmin,
|
||||
temperature_max: tmax
|
||||
}
|
||||
logJSON('wall.weather', { latitude, longitude, info }, 'wall')
|
||||
return info
|
||||
} catch (e) {
|
||||
logJSON('wall.weather.error', { error: String(e.message || e) }, 'wall')
|
||||
return { source: 'open-meteo', city: '上海', temperature: null, apparent_temperature: null, precipitation: null, precipitation_probability: null, us_aqi: null, pm2_5: null }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchWeather }
|
||||
|
||||
const cfgPath = path.join(process.cwd(), 'config', 'wall.json')
|
||||
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({
|
||||
autoplay_ms: 45000,
|
||||
swipe_pause_ms: 5 * 60 * 1000,
|
||||
max_cards_on_screen: 2,
|
||||
background: { url: "", mode: "tile", size: 120, opacity: 0.08 },
|
||||
cards: [
|
||||
{ title: '轻断食 · 吃什么', items: ['清汤/水', '清蒸蛋/少量蛋白', '蔬菜少油', '避免高糖与精制', '避免冷饮'], meta: '场景:晨间/午后 · 时长≤3秒读' },
|
||||
{ title: '冷水澡 · 准备步骤', items: ['热身2–3分钟', '先温水→冷水渐进', '重点避头部直冲', '备干毛巾与保暖', '注意心率与眩晕'], meta: '场景:沐浴前 · 安全优先' },
|
||||
{ title: '跑步 · 前置准备', items: ['热身动态拉伸', '检查鞋与袜', '补水少量', '路线与天气确认', '佩戴反光/夜跑灯'], meta: '场景:运动前 · 目标安全' }
|
||||
]
|
||||
}, null, 2))
|
||||
|
||||
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||||
const setConfig = cfg => fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
|
||||
|
||||
module.exports.getConfig = getConfig
|
||||
module.exports.setConfig = setConfig
|
||||
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||||
const wallDbPath = path.join(dataDir, 'call.db')
|
||||
const wallDb = new Database(wallDbPath)
|
||||
|
||||
wallDb.exec(`CREATE TABLE IF NOT EXISTS wall_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
items_json TEXT NOT NULL,
|
||||
meta TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);`)
|
||||
|
||||
const countCardsRow = () => {
|
||||
try {
|
||||
const row = wallDb.prepare('SELECT COUNT(*) AS c FROM wall_cards').get()
|
||||
if (row && typeof row.c === 'number') return row.c
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const ensureInitialCardsFromConfig = () => {
|
||||
try {
|
||||
if (countCardsRow() > 0) return
|
||||
const cfg = getConfig()
|
||||
const cards = Array.isArray(cfg.cards) ? cfg.cards : []
|
||||
if (!cards.length) return
|
||||
const insert = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
|
||||
const tx = wallDb.transaction((list) => {
|
||||
for (const c of list) {
|
||||
const items = Array.isArray(c.items) ? c.items : []
|
||||
insert.run({ title: String(c.title || ''), items_json: JSON.stringify(items), meta: String(c.meta || '') })
|
||||
}
|
||||
})
|
||||
tx(cards)
|
||||
try { logJSON('wall.cards.migrate_from_config', { count: cards.length }, 'wall') } catch {}
|
||||
} catch (e) {
|
||||
try { logJSON('wall.cards.migrate_from_config.error', { error: String(e.message || e) }, 'wall') } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
ensureInitialCardsFromConfig()
|
||||
|
||||
const listCardsStmt = wallDb.prepare(`SELECT id, title, items_json, meta, created_at, updated_at FROM wall_cards ORDER BY id ASC`)
|
||||
const insertCardStmt = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
|
||||
const updateCardStmt = wallDb.prepare(`UPDATE wall_cards SET title=@title, items_json=@items_json, meta=@meta, updated_at=datetime('now') WHERE id=@id`)
|
||||
const deleteCardStmt = wallDb.prepare(`DELETE FROM wall_cards WHERE id=?`)
|
||||
|
||||
const normalizeItems = (items) => {
|
||||
if (Array.isArray(items)) return items.map(x => String(x || ''))
|
||||
const text = String(items || '')
|
||||
if (!text.trim()) return []
|
||||
return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const listCards = () => {
|
||||
const rows = listCardsStmt.all()
|
||||
return rows.map(r => {
|
||||
let items = []
|
||||
try {
|
||||
const parsed = JSON.parse(r.items_json || '[]')
|
||||
if (Array.isArray(parsed)) items = parsed.map(x => String(x || ''))
|
||||
} catch {}
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title || '',
|
||||
items,
|
||||
meta: r.meta || '',
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createCard = (payload) => {
|
||||
const items = normalizeItems(payload.items)
|
||||
const items_json = JSON.stringify(items)
|
||||
const title = String(payload.title || '')
|
||||
const meta = String(payload.meta || '')
|
||||
const info = insertCardStmt.run({ title, items_json, meta })
|
||||
try { logJSON('wall.card.create', { id: info.lastInsertRowid, title }, 'wall') } catch {}
|
||||
return info.lastInsertRowid
|
||||
}
|
||||
|
||||
const updateCardById = (id, payload) => {
|
||||
const items = normalizeItems(payload.items)
|
||||
const items_json = JSON.stringify(items)
|
||||
const title = String(payload.title || '')
|
||||
const meta = String(payload.meta || '')
|
||||
updateCardStmt.run({ id, title, items_json, meta })
|
||||
try { logJSON('wall.card.update', { id, title }, 'wall') } catch {}
|
||||
}
|
||||
|
||||
const deleteCardById = (id) => {
|
||||
deleteCardStmt.run(id)
|
||||
try { logJSON('wall.card.delete', { id }, 'wall') } catch {}
|
||||
}
|
||||
|
||||
module.exports.listCards = listCards
|
||||
module.exports.createCard = createCard
|
||||
module.exports.updateCardById = updateCardById
|
||||
module.exports.deleteCardById = deleteCardById
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user