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 }, lua_versions: cfg.lua_versions || {} }); }); 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) { const cards = (DataService.getIcCards() || []) .slice() .sort((a, b) => Number(b?.created_ts || 0) - Number(a?.created_ts || 0)) .map((card) => ({ ...presentIcCard(card), status_label: mapIcCardStatus(card.status), card_type_label: mapIcCardType(card.card_type) })); appendReqLog(req, { category: 'public', type: 'ic_card_query_all', detail: { total: cards.length } }); return res.json({ ok: true, cards }); } 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;