347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
const express = require('express')
|
||
const { getSituation } = require('./situationData')
|
||
const { getStats } = require('./stats')
|
||
const db = require('./db')
|
||
|
||
const router = express.Router()
|
||
|
||
// 简单鉴权:通过环境变量配置的 API_ADMIN_KEY 保护敏感接口(不返回真实密钥)
|
||
const ADMIN_API_KEY = process.env.API_ADMIN_KEY || ''
|
||
|
||
function requireAdmin(req, res, next) {
|
||
if (!ADMIN_API_KEY) {
|
||
return res.status(500).json({ error: 'admin key not configured' })
|
||
}
|
||
const token = req.headers['x-api-key']
|
||
if (typeof token !== 'string' || token !== ADMIN_API_KEY) {
|
||
return res.status(401).json({ error: 'unauthorized' })
|
||
}
|
||
return next()
|
||
}
|
||
|
||
// 数据库 Dashboard:返回各表原始数据(需 admin 鉴权)
|
||
router.get('/db/dashboard', requireAdmin, (req, res) => {
|
||
try {
|
||
const tables = [
|
||
'feedback',
|
||
'situation',
|
||
'force_summary',
|
||
'power_index',
|
||
'force_asset',
|
||
'key_location',
|
||
'combat_losses',
|
||
'wall_street_trend',
|
||
'retaliation_current',
|
||
'retaliation_history',
|
||
'situation_update',
|
||
'news_content',
|
||
'gdelt_events',
|
||
'conflict_stats',
|
||
]
|
||
const data = {}
|
||
const timeSort = {
|
||
feedback: 'created_at DESC',
|
||
situation: 'updated_at DESC',
|
||
situation_update: 'timestamp DESC',
|
||
news_content: 'published_at DESC',
|
||
gdelt_events: 'event_time DESC',
|
||
wall_street_trend: 'time DESC',
|
||
retaliation_history: 'time DESC',
|
||
conflict_stats: 'updated_at DESC',
|
||
}
|
||
for (const name of tables) {
|
||
try {
|
||
const order = timeSort[name]
|
||
let rows
|
||
try {
|
||
rows = order
|
||
? db.prepare(`SELECT * FROM ${name} ORDER BY ${order}`).all()
|
||
: db.prepare(`SELECT * FROM ${name}`).all()
|
||
} catch (qerr) {
|
||
rows = db.prepare(`SELECT * FROM ${name}`).all()
|
||
}
|
||
data[name] = rows
|
||
} catch (e) {
|
||
data[name] = { error: e.message }
|
||
}
|
||
}
|
||
res.json(data)
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
// 资讯内容(独立表,供后续消费,可选 admin key;若配置了 ADMIN_API_KEY 则也要求鉴权)
|
||
router.get('/news', (req, res) => {
|
||
if (ADMIN_API_KEY) {
|
||
const token = req.headers['x-api-key']
|
||
if (typeof token !== 'string' || token !== ADMIN_API_KEY) {
|
||
return res.status(401).json({ error: 'unauthorized' })
|
||
}
|
||
}
|
||
try {
|
||
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200)
|
||
const rows = db.prepare('SELECT id, title, summary, url, source, published_at, category, severity, created_at FROM news_content ORDER BY published_at DESC LIMIT ?').all(limit)
|
||
res.json({ items: rows })
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
router.get('/situation', (req, res) => {
|
||
try {
|
||
res.json(getSituation())
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
// 来访统计:记录 IP(或开发环境下每标签 viewer-id),返回在看/看过
|
||
function getClientIp(req) {
|
||
const forwarded = req.headers['x-forwarded-for']
|
||
if (forwarded) return forwarded.split(',')[0].trim()
|
||
return req.ip || req.socket?.remoteAddress || 'unknown'
|
||
}
|
||
|
||
function getVisitKey(req) {
|
||
const vid = req.headers['x-viewer-id']
|
||
const ip = getClientIp(req)
|
||
const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
|
||
if (typeof vid === 'string' && vid.trim().length > 0 && (process.env.NODE_ENV === 'development' || isLocal)) {
|
||
return 'vid:' + vid.trim()
|
||
}
|
||
return ip
|
||
}
|
||
|
||
router.post('/visit', (req, res) => {
|
||
try {
|
||
const visitKey = getVisitKey(req)
|
||
db.prepare(
|
||
"INSERT OR REPLACE INTO visits (ip, last_seen) VALUES (?, datetime('now'))"
|
||
).run(visitKey)
|
||
db.prepare(
|
||
'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
|
||
).run()
|
||
const broadcast = req.app?.get?.('broadcastSituation')
|
||
if (typeof broadcast === 'function') broadcast()
|
||
res.json(getStats())
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
|
||
}
|
||
})
|
||
|
||
router.post('/feedback', (req, res) => {
|
||
try {
|
||
const content = (req.body?.content ?? '').toString().trim()
|
||
if (!content || content.length > 2000) {
|
||
return res.status(400).json({ ok: false, error: '留言内容 1–2000 字' })
|
||
}
|
||
const ip = getClientIp(req)
|
||
db.prepare(
|
||
'INSERT INTO feedback (content, ip) VALUES (?, ?)'
|
||
).run(content.slice(0, 2000), ip)
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ ok: false, error: err.message })
|
||
}
|
||
})
|
||
|
||
router.post('/share', (req, res) => {
|
||
try {
|
||
db.prepare(
|
||
'INSERT INTO share_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
|
||
).run()
|
||
const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
|
||
res.json({ ok: true, shareCount })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ ok: false, shareCount: 0 })
|
||
}
|
||
})
|
||
|
||
router.get('/stats', (req, res) => {
|
||
try {
|
||
res.json(getStats())
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0 })
|
||
}
|
||
})
|
||
|
||
router.get('/events', (req, res) => {
|
||
try {
|
||
const s = getSituation()
|
||
res.json({
|
||
updated_at: s.lastUpdated,
|
||
count: (s.conflictEvents || []).length,
|
||
events: s.conflictEvents || [],
|
||
conflict_stats: s.conflictStats || {},
|
||
})
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
// ---------- 手动修正看板数据(编辑页用) ----------
|
||
function broadcastAfterEdit(req) {
|
||
try {
|
||
const broadcast = req.app?.get?.('broadcastSituation')
|
||
if (typeof broadcast === 'function') broadcast()
|
||
} catch (_) {}
|
||
}
|
||
|
||
/** GET 原始可编辑数据:战损、据点、事件脉络、军力概要 */
|
||
router.get('/edit/raw', (req, res) => {
|
||
try {
|
||
const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us')
|
||
const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran')
|
||
const locUs = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us')
|
||
const locIr = db.prepare('SELECT id, side, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran')
|
||
const updates = db.prepare('SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 80').all()
|
||
const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
|
||
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
|
||
res.json({
|
||
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
|
||
keyLocations: { us: locUs || [], iran: locIr || [] },
|
||
situationUpdates: updates || [],
|
||
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
|
||
})
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
/** PUT 更新战损(美/伊) */
|
||
router.put('/edit/combat-losses', (req, res) => {
|
||
try {
|
||
const side = req.body?.side
|
||
if (side !== 'us' && side !== 'iran') {
|
||
return res.status(400).json({ error: 'side must be us or iran' })
|
||
}
|
||
const row = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get(side)
|
||
if (!row) return res.status(404).json({ error: 'combat_losses row not found' })
|
||
const cols = ['bases_destroyed', 'bases_damaged', 'personnel_killed', 'personnel_wounded',
|
||
'civilian_killed', 'civilian_wounded', 'aircraft', 'warships', 'armor', 'vehicles',
|
||
'drones', 'missiles', 'helicopters', 'submarines', 'tanks', 'carriers', 'civilian_ships', 'airport_port']
|
||
const updates = []
|
||
const values = []
|
||
for (const c of cols) {
|
||
if (req.body[c] !== undefined) {
|
||
updates.push(`${c} = ?`)
|
||
values.push(Number(req.body[c]) || 0)
|
||
}
|
||
}
|
||
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
|
||
values.push(side)
|
||
db.prepare(`UPDATE combat_losses SET ${updates.join(', ')} WHERE side = ?`).run(...values)
|
||
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||
broadcastAfterEdit(req)
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
/** PATCH 更新单个据点 */
|
||
router.patch('/edit/key-location/:id', (req, res) => {
|
||
try {
|
||
const id = parseInt(req.params.id, 10)
|
||
if (!Number.isFinite(id)) return res.status(400).json({ error: 'invalid id' })
|
||
const row = db.prepare('SELECT id FROM key_location WHERE id = ?').get(id)
|
||
if (!row) return res.status(404).json({ error: 'key_location not found' })
|
||
const allowed = ['name', 'lat', 'lng', 'type', 'region', 'status', 'damage_level']
|
||
const updates = []
|
||
const values = []
|
||
for (const k of allowed) {
|
||
if (req.body[k] !== undefined) {
|
||
if (k === 'status' && !['operational', 'damaged', 'attacked'].includes(req.body[k])) continue
|
||
updates.push(`${k} = ?`)
|
||
values.push(k === 'lat' || k === 'lng' ? Number(req.body[k]) : req.body[k])
|
||
}
|
||
}
|
||
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
|
||
values.push(id)
|
||
db.prepare(`UPDATE key_location SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||
broadcastAfterEdit(req)
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
/** POST 新增事件脉络 */
|
||
router.post('/edit/situation-update', (req, res) => {
|
||
try {
|
||
const id = (req.body?.id || '').toString().trim() || `man_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
||
const timestamp = (req.body?.timestamp || new Date().toISOString()).toString().trim()
|
||
const category = (req.body?.category || 'other').toString().toLowerCase()
|
||
const summary = (req.body?.summary || '').toString().trim().slice(0, 500)
|
||
const severity = (req.body?.severity || 'medium').toString().toLowerCase()
|
||
if (!summary) return res.status(400).json({ error: 'summary required' })
|
||
const validCat = ['deployment', 'alert', 'intel', 'diplomatic', 'other'].includes(category) ? category : 'other'
|
||
const validSev = ['low', 'medium', 'high', 'critical'].includes(severity) ? severity : 'medium'
|
||
db.prepare('INSERT OR REPLACE INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)').run(id, timestamp, validCat, summary, validSev)
|
||
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||
broadcastAfterEdit(req)
|
||
res.json({ ok: true, id })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
/** DELETE 删除一条事件脉络 */
|
||
router.delete('/edit/situation-update/:id', (req, res) => {
|
||
try {
|
||
const id = (req.params.id || '').toString().trim()
|
||
if (!id) return res.status(400).json({ error: 'id required' })
|
||
const r = db.prepare('DELETE FROM situation_update WHERE id = ?').run(id)
|
||
if (r.changes === 0) return res.status(404).json({ error: 'not found' })
|
||
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||
broadcastAfterEdit(req)
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
/** PUT 更新军力概要(美/伊) */
|
||
router.put('/edit/force-summary', (req, res) => {
|
||
try {
|
||
const side = req.body?.side
|
||
if (side !== 'us' && side !== 'iran') {
|
||
return res.status(400).json({ error: 'side must be us or iran' })
|
||
}
|
||
const cols = ['total_assets', 'personnel', 'naval_ships', 'aircraft', 'ground_units', 'uav', 'missile_consumed', 'missile_stock']
|
||
const updates = []
|
||
const values = []
|
||
for (const c of cols) {
|
||
if (req.body[c] !== undefined) {
|
||
updates.push(`${c} = ?`)
|
||
values.push(Number(req.body[c]) || 0)
|
||
}
|
||
}
|
||
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
|
||
values.push(side)
|
||
db.prepare(`UPDATE force_summary SET ${updates.join(', ')} WHERE side = ?`).run(...values)
|
||
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||
broadcastAfterEdit(req)
|
||
res.json({ ok: true })
|
||
} catch (err) {
|
||
console.error(err)
|
||
res.status(500).json({ error: err.message })
|
||
}
|
||
})
|
||
|
||
module.exports = router
|