Files
usa/server/routes.js
2026-03-03 20:17:38 +08:00

347 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '留言内容 12000 字' })
}
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