Files
usa/server/routes.js
2026-03-04 00:39:01 +08:00

415 lines
15 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'
}
// 优先用前端传来的 viewer-id 去重(每设备一个),否则用 IP这样多设备同 WiFi 也能正确统计「在看」
function getVisitKey(req) {
const vid = req.headers['x-viewer-id']
const ip = getClientIp(req)
if (typeof vid === 'string' && vid.trim().length > 0) {
return 'vid:' + vid.trim().slice(0, 64)
}
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, likeCount: 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.post('/like', (req, res) => {
try {
db.prepare(
'INSERT INTO like_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1'
).run()
res.json(getStats())
} catch (err) {
console.error(err)
res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 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, likeCount: 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')
let displayStats = null
try {
displayStats = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
} catch (_) {}
const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0
const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
const liveViewers = db.prepare(
"SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get()?.n ?? 0
const realFeedback = db.prepare('SELECT COUNT(*) as n FROM feedback').get()?.n ?? 0
let realLikeCount = 0
try {
realLikeCount = db.prepare('SELECT total FROM like_count WHERE id = 1').get()?.total ?? 0
} catch (_) {}
res.json({
combatLosses: { us: lossesUs || null, iran: lossesIr || null },
keyLocations: { us: locUs || [], iran: locIr || [] },
situationUpdates: updates || [],
forceSummary: { us: summaryUs || null, iran: summaryIr || null },
displayStats: {
viewers: displayStats?.viewers ?? liveViewers,
cumulative: displayStats?.cumulative ?? realCumulative,
shareCount: displayStats?.share_count ?? realShare,
likeCount: displayStats?.like_count ?? realLikeCount,
feedbackCount: displayStats?.feedback_count ?? realFeedback,
},
})
} 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 })
}
})
/** PUT 更新展示统计(看过、在看、分享、点赞、留言)。传 null 表示清除覆盖、改用实时统计 */
router.put('/edit/display-stats', (req, res) => {
try {
db.prepare('INSERT OR IGNORE INTO display_stats (id) VALUES (1)').run()
const updates = []
const values = []
const setField = (key, bodyKey) => {
const v = req.body?.[bodyKey ?? key]
if (v === undefined) return
if (v === null) {
updates.push(`${key} = ?`)
values.push(null)
return
}
const n = Math.max(0, parseInt(v, 10))
if (!Number.isFinite(n)) throw new Error(`${bodyKey ?? key} must be number`)
updates.push(`${key} = ?`)
values.push(n)
}
setField('viewers')
setField('cumulative')
setField('share_count', 'shareCount')
setField('like_count', 'likeCount')
setField('feedback_count', 'feedbackCount')
if (updates.length === 0) return res.status(400).json({ error: 'no fields to update' })
values.push(1)
db.prepare(`UPDATE display_stats SET ${updates.join(', ')} WHERE id = ?`).run(...values)
broadcastAfterEdit(req)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(400).json({ error: err.message })
}
})
module.exports = router