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 }