初始提交

This commit is contained in:
2026-06-21 10:00:13 +08:00
commit 7a5dc32672
1441 changed files with 266348 additions and 0 deletions
+756
View File
@@ -0,0 +1,756 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const DataService = require('../services/data');
const LogicService = require('../services/logic');
const io = require('../io');
const svgGenerator = require('../services/svg-generator');
// Helper to get IP
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
const appendReqLog = (req, { category, type, detail, source, level } = {}) => {
const entry = {
ts: new Date().toISOString(),
ip: getIp(req),
ua: String(req.headers['user-agent'] || ''),
method: req.method,
path: req.originalUrl || req.path || '',
category: String(category || '').trim() || 'public',
source: source == null ? undefined : String(source || '').trim(),
level: level == null ? undefined : String(level || '').trim(),
type: String(type || '').trim() || 'event',
detail: (detail === undefined) ? null : detail
};
DataService.appendLog(entry);
};
const buildStationResolver = () => {
const stations = DataService.getStations?.() || [];
const codeByKey = new Map();
const nameToCode = new Map();
const normKey = (v) => String(v || '').trim().toUpperCase().replace(/\s+/g, '');
const looksLikeStationCode = (v) => /^[A-Z0-9]+(?:-[A-Z0-9]+)+$/i.test(String(v || '').trim());
for (const s of stations) {
if (!s) continue;
const code = String(s.code || '').trim();
if (!code) continue;
codeByKey.set(normKey(code), code);
const cn = String(s.name || s.cn_name || s.station_name || '').trim();
if (cn) nameToCode.set(normKey(cn), code);
const en = String(s.en_name || s.en || s.enName || '').trim();
if (en) nameToCode.set(normKey(en), code);
}
return (v) => {
const raw = String(v || '').trim();
if (!raw) return '';
const key = normKey(raw);
if (codeByKey.has(key)) return codeByKey.get(key);
if (looksLikeStationCode(raw)) return raw;
return nameToCode.get(key) || raw;
};
};
const normalizeTicketId = (v) => {
const s0 = String(v || '').replace(/\s+/g, '');
if (!s0) return '';
const m = s0.match(/^([A-Za-z]{2})-?([0-9]+)$/);
if (m) {
const prefix = m[1].toUpperCase();
let num = m[2];
if (num.length < 8) num = num.padStart(8, '0');
else if (num.length > 8) num = num.slice(-8);
return `${prefix}-${num}`;
}
return s0;
};
const normalizeIcCardId = (v) => {
const s0 = String(v || '').replace(/\s+/g, '').toUpperCase();
if (!s0) return '';
const m = s0.match(/^IC-?([0-9]+)$/);
if (m) return `IC-${m[1].padStart(6, '0').slice(-6)}`;
return s0;
};
const buildIcCardId = () => {
const idx = DataService.getIcCardIndex() || {};
let id = '';
do {
id = `IC-${String(crypto.randomInt(0, 1000000)).padStart(6, '0')}`;
} while (idx[id]);
return id;
};
const buildIcCardOrderCode = () => {
const cards = DataService.getIcCards() || [];
let code = LogicService.genVoucherCode();
while (cards.some((item) => String(item?.order_code || item?.voucher_code || item?.code || '').trim().toUpperCase() === code)) {
code = LogicService.genVoucherCode();
}
return code;
};
const getIcCardCatalog = () => ([
{
id: 'stored_value',
name: '储值卡',
description: '支持反复充值,适合日常乘车。',
deposit: 0,
min_initial_balance: 1,
recommended_initial_balance: 5,
fixed_amount: null,
recharge_options: [5, 10, 15, 20]
}
]);
const getIcCardPlan = (cardType) => {
const plans = getIcCardCatalog();
return plans.find((item) => item.id === String(cardType || '').trim()) || plans[0];
};
const IC_CARD_HOLDER_NAME_RE = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
const displayIcCardId = (card) => {
const status = String(card?.status || '').trim().toLowerCase();
const source = String(card?.source || '').trim().toLowerCase();
const rawId = String(card?.card_id || '').trim();
if (status === 'pending_pickup' && source === 'online') return '待出卡';
return rawId || '---';
};
const presentIcCard = (card) => card ? ({
...card,
display_card_id: displayIcCardId(card)
}) : card;
const mapIcCardStatus = (status) => {
const s = String(status || '').trim().toLowerCase();
if (s === 'pending_pickup') return '待领卡';
if (s === 'active') return '正常';
if (s === 'disabled') return '停用';
if (s === 'lost') return '挂失';
if (s === 'refunded') return '已退卡';
return status || '未知';
};
const mapIcCardType = (type) => {
const t = String(type || '').trim().toLowerCase();
if (t === 'stored_value') return '储值卡';
if (t === 'monthly') return '月票卡';
if (t === 'tourist') return '纪念卡';
return type || '未分类';
};
// Basic Info
router.get('/health', (req, res) => res.json({ ok: true }));
router.get('/stations', (req, res) => {
const list = DataService.getStations();
res.json(list.map(s => ({
name: s.name || s.cn_name || s.station_name || '',
en_name: s.en_name || s.enName || '',
code: s.code || ''
})));
});
router.get('/lines', (req, res) => {
const list = DataService.getLines();
const map = LogicService.buildStationNameMap();
const nameFor = (code) => (map && map[code]) || code;
res.json(list.map(l => ({
line_id: l.id || '',
name: l.name || l.cn_name || l.en_name || '',
color: l.color || l.colour || '',
stop_names: Array.isArray(l.stations) ? l.stations.map(c => nameFor(c)) : (Array.isArray(l.stops) ? l.stops.map(c => nameFor(c)) : []),
stops: Array.isArray(l.stations) ? l.stations : (Array.isArray(l.stops) ? l.stops : [])
})));
});
router.get('/fares', (req, res) => {
const list = DataService.getFares();
const map = LogicService.buildStationNameMap();
const nameFor = (code) => (map && map[code]) || code;
res.json(list.map(f => ({
from_name: nameFor(f.from),
to_name: nameFor(f.to),
regular_fare: f.cost_regular ?? f.cost ?? 0,
express_fare: f.cost_express ?? f.cost ?? 0
})));
});
router.get('/fares/query', (req, res) => {
const { from, to } = req.query;
if (!from || !to) {
appendReqLog(req, { category: 'public', type: 'fare_query_invalid', level: 'warn', detail: { from, to } });
return res.status(400).json({ error: 'missing_from_or_to' });
}
const resolveStation = buildStationResolver();
const fromCode = resolveStation(from);
const toCode = resolveStation(to);
const result = LogicService.computeFareBoth(fromCode, toCode);
if (result) {
const cfg = DataService.getConfig();
const discountRaw = Number(cfg?.promotion?.discount ?? 1);
const discount = Number.isFinite(discountRaw) && discountRaw > 0 ? discountRaw : 1;
const regularBase = Number(result.regular);
const expressBase = Number(result.express);
const regularDiscounted = Number.isFinite(regularBase) ? Math.floor(regularBase * discount) : null;
const expressDiscounted = Number.isFinite(expressBase) ? Math.floor(expressBase * discount) : null;
appendReqLog(req, { category: 'public', type: 'fare_query', detail: { from, to, from_code: fromCode, to_code: toCode, ok: true } });
res.json({
from_code: fromCode || null,
to_code: toCode || null,
regular_fare: result.regular ?? null,
express_fare: result.express ?? null,
discounted_regular_fare: regularDiscounted,
discounted_express_fare: expressDiscounted,
discount,
regular_path: result.regular_path ?? null,
express_path: result.express_path ?? null,
regular_transfers: result.regular_transfers ?? null,
express_transfers: result.express_transfers ?? null
});
} else {
appendReqLog(req, { category: 'public', type: 'fare_query', detail: { from, to, from_code: fromCode, to_code: toCode, ok: false } });
res.json({ error: 'fare_not_found' });
}
});
router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 }
});
});
router.get('/ic-cards/config', (req, res) => {
const plan = getIcCardPlan('stored_value');
res.json({
ok: true,
cards: getIcCardCatalog(),
recharge_options: Array.isArray(plan.recharge_options) ? plan.recharge_options : [5, 10, 15, 20],
initial_balance_min: Number(plan.min_initial_balance || 1) || 1,
holder_name_pattern: IC_CARD_HOLDER_NAME_RE.source,
holder_name_hint: '仅支持英文与常用符号'
});
});
// Ticket Orders
router.post('/orders', async (req, res) => {
const { start, terminal, train_type, trips, ride_date } = req.body || {};
const from = String(start || '').trim();
const to = String(terminal || '').trim();
const type = String(train_type || '').trim() || 'Local';
const t = Math.max(1, Number(trips || 1));
const date = String(ride_date || '').trim();
if (!from || !to || !date) {
appendReqLog(req, { category: 'public', type: 'order_create_invalid', level: 'warn', detail: { start: from, terminal: to, ride_date: date } });
return res.status(400).json({ ok: false, error: 'missing start/terminal/ride_date' });
}
const resolveStation = buildStationResolver();
const fromCode = resolveStation(from);
const toCode = resolveStation(to);
const priceSingle = LogicService.computePrice(fromCode, toCode, type);
const price = Math.max(0, priceSingle * t);
const code = LogicService.genVoucherCode();
const rec = { code, start: fromCode, terminal: toCode, train_type: type, trips: t, ride_date: date, price, created_ts: Date.now(), consumed: false };
const list = DataService.getOrders();
list.push(rec);
await DataService.saveOrders(list);
const idx = DataService.getOrderIndex();
idx[code] = rec;
await DataService.saveOrderIndex(idx);
io.emit('order:created', rec);
appendReqLog(req, { category: 'public', type: 'order_create', detail: { code, start: from, terminal: to, start_code: fromCode, terminal_code: toCode, train_type: type, trips: t, ride_date: date, price } });
res.json({ ok: true, code, price });
});
// Voucher Detail
router.get('/orders/:code', (req, res) => {
const code = String(req.params.code || '').trim().toUpperCase();
if (!code) return res.status(400).json({ ok: false, error: 'code required' });
const idx = DataService.getOrderIndex();
const order = idx[code];
if (!order) {
appendReqLog(req, { category: 'public', type: 'order_query', detail: { code, ok: false } });
return res.status(404).json({ ok: false, error: 'not found' });
}
const map = LogicService.buildStationNameMap();
const nameFor = (c) => (map && map[c]) || c;
const stations = DataService.getStations() || [];
const enNameFor = (c) => {
const s = stations.find(x => x.code === c);
return s ? (s.en_name || s.enName || '') : '';
};
appendReqLog(req, { category: 'public', type: 'order_query', detail: { code, ok: true } });
res.json({
ok: true,
data: {
...order,
start_name: nameFor(order.start),
start_en: enNameFor(order.start),
terminal_name: nameFor(order.terminal),
terminal_en: enNameFor(order.terminal)
}
});
});
router.post('/orders/:code/consume', async (req, res) => {
const code = String(req.params.code || '').trim().toUpperCase();
if (!code) return res.status(400).json({ ok: false, error: 'code required' });
const idx = DataService.getOrderIndex();
const order = idx[code];
if (!order) {
appendReqLog(req, { category: 'device', type: 'order_consume', detail: { code, ok: false, reason: 'not_found', device: req.body?.device } });
return res.status(404).json({ ok: false, error: 'not found' });
}
if (order.consumed) {
appendReqLog(req, { category: 'device', type: 'order_consume', detail: { code, ok: false, reason: 'already_consumed', device: req.body?.device } });
return res.status(409).json({ ok: false, error: 'already consumed' });
}
// Mark as consumed
order.consumed = true;
order.consumed_ts = Date.now();
order.device = req.body.device || 'ticket_machine'; // optional tracking
// Update main list
const list = DataService.getOrders();
const listIdx = list.findIndex(o => o.code === code);
if (listIdx >= 0) {
list[listIdx] = order;
await DataService.saveOrders(list);
}
// Update index
idx[code] = order;
await DataService.saveOrderIndex(idx);
io.emit('order:consumed', order);
appendReqLog(req, { category: 'device', type: 'order_consume', detail: { code, ok: true, device: order.device } });
res.json({ ok: true, code });
});
// IC Card Public APIs
router.get('/ic-cards/query', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) {
appendReqLog(req, { category: 'public', type: 'ic_card_query_invalid', level: 'warn', detail: { q } });
return res.status(400).json({ ok: false, error: 'query required' });
}
const normCardId = normalizeIcCardId(q);
const normOrderCode = String(q || '').trim().toUpperCase();
const card = (DataService.getIcCards() || []).find((item) => {
const cardId = normalizeIcCardId(item?.card_id);
const orderCode = String(item?.order_code || '').trim().toUpperCase();
const voucherCode = String(item?.voucher_code || item?.code || '').trim().toUpperCase();
return cardId === normCardId || orderCode === normOrderCode || voucherCode === normOrderCode;
});
if (!card) {
appendReqLog(req, { category: 'public', type: 'ic_card_query', detail: { q, ok: false } });
return res.status(404).json({ ok: false, error: 'ic card not found' });
}
const events = await DataService.getIcCardEvents(card.card_id);
appendReqLog(req, { category: 'public', type: 'ic_card_query', detail: { q, card_id: card.card_id, ok: true } });
res.json({
ok: true,
card: {
...presentIcCard(card),
status_label: mapIcCardStatus(card.status),
card_type_label: mapIcCardType(card.card_type)
},
events
});
});
router.get('/ic-cards/orders/:code', async (req, res) => {
const code = String(req.params.code || '').trim().toUpperCase();
if (!code) return res.status(400).json({ ok: false, error: 'code required' });
const card = (DataService.getIcCards() || []).find((item) => {
const orderCode = String(item?.order_code || '').trim().toUpperCase();
const voucherCode = String(item?.voucher_code || item?.code || '').trim().toUpperCase();
return orderCode === code || voucherCode === code;
});
if (!card) {
appendReqLog(req, { category: 'public', type: 'ic_card_order_query', detail: { code, ok: false } });
return res.status(404).json({ ok: false, error: 'not found' });
}
appendReqLog(req, { category: 'public', type: 'ic_card_order_query', detail: { code, ok: true, card_id: card.card_id } });
res.json({
ok: true,
data: {
...presentIcCard(card),
status_label: mapIcCardStatus(card.status),
card_type_label: mapIcCardType(card.card_type)
}
});
});
router.post('/ic-cards/orders', async (req, res) => {
try {
const body = req.body || {};
const holder_name = String(body.holder_name || '').trim();
if (!holder_name) {
appendReqLog(req, { category: 'public', type: 'ic_card_order_invalid', level: 'warn', detail: { error: 'holder_name required', payload: body } });
return res.status(400).json({ ok: false, error: 'holder_name required' });
}
if (!IC_CARD_HOLDER_NAME_RE.test(holder_name)) {
appendReqLog(req, { category: 'public', type: 'ic_card_order_invalid', level: 'warn', detail: { error: 'holder_name pattern invalid', payload: body } });
return res.status(400).json({ ok: false, error: 'holder_name must use English letters and symbols only' });
}
const plan = getIcCardPlan('stored_value');
const initial_balance = Math.floor(Math.max(0, Number(body.initial_balance ?? plan.recommended_initial_balance ?? 0) || 0));
if (initial_balance < Number(plan.min_initial_balance || 1)) {
appendReqLog(req, { category: 'public', type: 'ic_card_order_invalid', level: 'warn', detail: { error: 'initial balance too low', payload: body } });
return res.status(400).json({ ok: false, error: `initial_balance must be >= ${plan.min_initial_balance}` });
}
const now = Date.now();
const card_id = buildIcCardId();
const order_code = buildIcCardOrderCode();
const purchase_amount = initial_balance;
const card = {
card_id,
order_code,
voucher_code: order_code,
code: order_code,
holder_name,
card_type: plan.id,
status: 'pending_pickup',
balance: initial_balance,
deposit: 0,
purchase_amount,
source: 'online',
created_ts: now,
last_update_ts: now
};
await DataService.upsertIcCard(card);
await DataService.appendIcCardEvent({
ts: now,
type: 'order_created',
card_id,
order_code,
detail: {
holder_name,
card_type: plan.id,
purchase_amount,
balance: initial_balance
}
});
io.emit('ic-card:created', { card_id, order_code, status: 'pending_pickup' });
appendReqLog(req, { category: 'public', type: 'ic_card_order_create', detail: { card_id, order_code, card_type: plan.id, purchase_amount } });
res.json({
ok: true,
code: order_code,
card_id,
display_card_id: displayIcCardId(card),
amount: purchase_amount,
card: {
...presentIcCard(card),
status_label: mapIcCardStatus(card.status),
card_type_label: mapIcCardType(card.card_type)
}
});
} catch (e) {
appendReqLog(req, { category: 'system', type: 'ic_card_order_create_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } });
res.status(500).json({ ok: false, error: 'failed to create ic card order' });
}
});
router.post('/tickets/record', async (req, res) => {
const b = req.body || {};
const ticket_id = normalizeTicketId(b.ticket_id || b.id);
if (!ticket_id) {
appendReqLog(req, { category: 'device', type: 'ticket_record_invalid', level: 'warn', detail: { error: 'ticket_id required', payload: b } });
return res.status(400).json({ ok: false, error: 'ticket_id required' });
}
const start = String(b.start || b.start_station_id || b.start_station || '').trim();
const terminal = String(b.terminal || b.terminal_station_id || b.end_station || '').trim();
const train_type = String(b.train_type || b.trainType || b.type || '').trim();
const cost = Number(b.cost || 0) || 0;
const station_code = String(b.station_code || b.stationCode || '').trim();
const device = String(b.device || b.device_id || b.deviceId || 'unknown');
const trips_total = (b.trips_total == null) ? undefined : (Number(b.trips_total) || 0);
const trips_remaining = (b.trips_remaining == null) ? undefined : (Number(b.trips_remaining) || 0);
const ev = {
ts: Date.now(),
type: 'sale',
ticket_id,
start,
terminal,
train_type,
cost,
station_code,
device,
trips_total,
trips_remaining
};
await DataService.appendTicketEvent(ev);
await DataService.upsertTicketIndex({
ticket_id,
start,
terminal,
train_type,
cost,
status: 'valid',
station_code,
last_event: 'sale',
start_name: b.start_name,
terminal_name: b.terminal_name,
start_en: b.start_name_en,
terminal_en: b.terminal_name_en,
trips_total,
trips_remaining,
last_update_ts: Date.now()
});
const now = new Date();
const statItem = {
device,
station_code,
sold_tickets: 1,
revenue: cost,
ts: Date.now(),
window_hour: now.getHours().toString().padStart(2, '0'),
window_day: now.toISOString().split('T')[0],
type: 'ticket'
};
await DataService.appendStatTicket(statItem);
io.emit('ticket:sale', ev);
io.emit('stats:ticket:updated', statItem);
appendReqLog(req, { category: 'device', type: 'ticket_sale', detail: ev });
res.json({ ok: true, ticket_id });
});
// Ticket Search
router.get('/tickets', (req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
const idx = DataService.getTicketIndex();
let list = Object.entries(idx).map(([ticket_id, data]) => ({ ticket_id, ...data }));
if (q) {
list = list.filter(t =>
String(t.ticket_id || '').toLowerCase().includes(q) ||
String(t.station_code || '').toLowerCase().includes(q) ||
String(t.start || '').toLowerCase().includes(q) ||
String(t.terminal || '').toLowerCase().includes(q)
);
}
list.sort((a, b) => Number(b.last_update_ts || 0) - Number(a.last_update_ts || 0));
list = list.map(t => {
const tripsRemaining = (t.trips_remaining ?? t.rides_remaining);
const tripsTotal = (t.trips_total ?? t.rides_total);
const shouldBeUsed =
(typeof tripsRemaining === 'number' && tripsRemaining <= 0) ||
((tripsTotal == null || Number(tripsTotal) <= 1) && String(t.last_action || '') === 'exit');
const status = (t.status && t.status !== 'valid') ? t.status : (shouldBeUsed ? 'used' : (t.status || 'valid'));
return { ...t, status };
});
// Format to Chinese keys as expected by ticket-search.js
const map = LogicService.buildStationNameMap();
const nameFor = (code) => (map && map[code]) || code;
const stations = DataService.getStations();
const enNameFor = (code) => {
const s = stations.find(x => x.code === code);
return s ? (s.en_name || s.enName || '') : '';
};
const formatted = list.map(t => ({
ticket_id: t.ticket_id,
start_name: t.start ? nameFor(t.start) : (t.start_name || ''),
start_code: t.start || '',
start_en: t.start ? enNameFor(t.start) : (t.start_en || ''),
terminal_name: t.terminal ? nameFor(t.terminal) : (t.terminal_name || ''),
terminal_code: t.terminal || '',
terminal_en: t.terminal ? enNameFor(t.terminal) : (t.terminal_en || ''),
train_type: t.train_type || '',
station_name: t.station_name || nameFor(t.station_code),
station_code: t.station_code || '',
trips_total: t.trips_total ?? 0,
trips_remaining: t.trips_remaining ?? null,
amount: t.cost ?? 0,
status: t.status || '',
last_event: t.last_event || '',
last_action: t.last_action || '',
last_station: nameFor(t.last_station_code),
last_update_ts: t.last_update_ts || 0
}));
appendReqLog(req, { category: 'public', type: 'ticket_search', detail: { q, count: formatted.length } });
res.json(formatted);
});
router.get('/tickets/:id', async (req, res, next) => {
try {
const rawId = String(req.params.id || '').trim();
const id0 = normalizeTicketId(rawId);
if (!rawId) return res.status(400).json({ error: 'ticket_id_required' });
const idx = DataService.getTicketIndex();
const candidates = Array.from(new Set([
rawId, rawId.toUpperCase(), rawId.toLowerCase(),
id0, id0.toUpperCase(), id0.toLowerCase()
])).filter(Boolean);
let id = id0 || rawId;
let t = null;
for (const c of candidates) {
if (idx[c]) { id = c; t = idx[c]; break; }
}
if (!t) {
const targetNorms = new Set(candidates.map(x => normalizeTicketId(x)).filter(Boolean));
for (const k of Object.keys(idx || {})) {
const nk = normalizeTicketId(k);
if (!nk) continue;
if (targetNorms.has(nk) || targetNorms.has(String(nk).toUpperCase()) || targetNorms.has(String(nk).toLowerCase())) {
id = k;
t = idx[k];
break;
}
}
}
if (!t) {
appendReqLog(req, { category: 'public', type: 'ticket_detail', detail: { ticket_id: (id0 || rawId), ok: false } });
return res.status(404).json({ ticket_id: (id0 || rawId), overview: null, events: [] });
}
const tripsRemaining = (t.trips_remaining ?? t.rides_remaining);
const tripsTotal = (t.trips_total ?? t.rides_total);
const shouldBeUsed =
(typeof tripsRemaining === 'number' && tripsRemaining <= 0) ||
((tripsTotal == null || Number(tripsTotal) <= 1) && String(t.last_action || '') === 'exit');
const status = (t.status && t.status !== 'valid') ? t.status : (shouldBeUsed ? 'used' : (t.status || 'valid'));
const allEvents = await DataService.readAllTicketEvents();
const events = (Array.isArray(allEvents) ? allEvents : []).filter(e => e && e.ticket_id === id);
const map = LogicService.buildStationNameMap();
const nameFor = (code) => (map && map[code]) || code;
const stations = DataService.getStations() || [];
const enNameFor = (code) => {
const s = stations.find(x => x.code === code);
return s ? (s.en_name || s.enName || '') : '';
};
const overview = {
ticket_id: id,
start_name: t.start ? nameFor(t.start) : (t.start_name || ''),
start_code: t.start || '',
start_en: t.start ? enNameFor(t.start) : (t.start_en || ''),
terminal_name: t.terminal ? nameFor(t.terminal) : (t.terminal_name || ''),
terminal_code: t.terminal || '',
terminal_en: t.terminal ? enNameFor(t.terminal) : (t.terminal_en || ''),
train_type: t.train_type || '',
station_name: t.station_name || nameFor(t.station_code),
station_code: t.station_code || '',
trips_total: t.trips_total ?? 0,
trips_remaining: t.trips_remaining ?? null,
amount: t.cost ?? 0,
status,
last_event: t.last_event || '',
last_action: t.last_action || '',
last_station: nameFor(t.last_station_code),
last_update_ts: t.last_update_ts || 0
};
const formattedEvents = events.map(e => {
if (e.type === 'sale') {
return {
type: 'sale',
ts: e.ts,
station_name: e.station_name || nameFor(e.station_code),
station_code: e.station_code || '',
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || ''),
ticket_id: e.ticket_id,
start_name: nameFor(e.start),
terminal_name: nameFor(e.terminal),
train_type: e.train_type || '',
trips_total: e.trips_total ?? 0,
amount: e.cost ?? 0
};
} else if (e.type === 'status') {
return {
type: 'status',
action: e.action,
ts: e.ts,
ticket_id: e.ticket_id,
station_name: nameFor(e.station_code),
station_code: e.station_code || '',
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || ''),
trips_remaining: e.trips_remaining ?? e.rides_remaining ?? null
};
}
return { raw: e };
});
appendReqLog(req, { category: 'public', type: 'ticket_detail', detail: { ticket_id: id, ok: true } });
res.json({ ticket_id: id, overview, events: formattedEvents });
} catch (err) {
appendReqLog(req, { category: 'system', type: 'ticket_detail_failed', level: 'error', detail: { error: err?.message || String(err) } });
next(err);
}
});
router.get('/popular', async (req, res) => {
const events = (await DataService.readAllTicketEvents()).filter(e => e && e.type === 'sale');
const cntStation = new Map();
const cntRoute = new Map();
for (const e of events) {
const k1 = e.start || ''; const k2 = e.terminal || '';
if (k1) cntStation.set(k1, (cntStation.get(k1) || 0) + 1);
if (k2) cntStation.set(k2, (cntStation.get(k2) || 0) + 1);
if (k1 && k2) { const key = `${k1}|${k2}`; cntRoute.set(key, (cntRoute.get(key) || 0) + 1); }
}
const map = LogicService.buildStationNameMap();
const nameByCode = map;
const topStations = Array.from(cntStation.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([code, count]) => ({ name: nameByCode[code] || code, code, count }));
const topRoutes = Array.from(cntRoute.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([key, count]) => { const [from, to] = key.split('|'); return { from: nameByCode[from] || from, to: nameByCode[to] || to, count }; });
res.json({ ok: true, topStations, topRoutes });
});
router.get('/fares/map/light', (req, res) => {
const stationTransfers = [];
for (const s of (DataService.getStations() || [])) {
const from = String(s?.code || '').trim();
if (!from) continue;
if (!s?.transfer_enabled) continue;
const toList = Array.isArray(s.transfer_to) ? s.transfer_to : [];
for (const t of toList) {
const to = String((t && typeof t === 'object') ? (t.code || t.station || t.id || '') : t).trim();
if (!to || to === from) continue;
stationTransfers.push([from, to]);
}
}
const mergedTransfers = [
...((DataService.getConfig().transfers || []).filter(x => Array.isArray(x) && x.length >= 2)),
...stationTransfers
];
const svg = svgGenerator.generate(
DataService.getStations(),
DataService.getLines(),
DataService.getFares(),
LogicService.buildStationNameMap(),
mergedTransfers
);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.send(svg);
});
module.exports = router;