初始提交
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const DataService = require('../services/data');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ASSETS_DIR = path.join(__dirname, '../../Assets');
|
||||
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
|
||||
|
||||
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() || 'admin',
|
||||
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 ensureDir = (p) => {
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
};
|
||||
|
||||
const readManifest = () => {
|
||||
try {
|
||||
if (!fs.existsSync(MANIFEST_PATH)) return {};
|
||||
const raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
|
||||
const data = JSON.parse(raw || '{}');
|
||||
return (data && typeof data === 'object') ? data : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeManifest = (m) => {
|
||||
ensureDir(ASSETS_DIR);
|
||||
const out = (m && typeof m === 'object') ? m : {};
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const removeByPrefix = (prefix) => {
|
||||
ensureDir(ASSETS_DIR);
|
||||
const list = fs.readdirSync(ASSETS_DIR);
|
||||
for (const name of list) {
|
||||
if (!name.startsWith(prefix + '.')) continue;
|
||||
const fp = path.join(ASSETS_DIR, name);
|
||||
try { fs.unlinkSync(fp); } catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
const storageForPrefix = (prefix) => multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
ensureDir(ASSETS_DIR);
|
||||
cb(null, ASSETS_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
removeByPrefix(prefix);
|
||||
cb(null, `${prefix}${ext || ''}`);
|
||||
}
|
||||
});
|
||||
|
||||
const buildUploader = ({ prefix, allowedExts }) => multer({
|
||||
storage: storageForPrefix(prefix),
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
if (!ext || !allowedExts.includes(ext)) return cb(new Error('不支持的文件类型'));
|
||||
cb(null, true);
|
||||
},
|
||||
limits: { fileSize: 20 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
const uploadRouteMap = buildUploader({
|
||||
prefix: 'route-map',
|
||||
allowedExts: ['.png', '.jpg', '.jpeg', '.webp', '.svg']
|
||||
});
|
||||
|
||||
const uploadFareTable = buildUploader({
|
||||
prefix: 'fare-table',
|
||||
allowedExts: ['.csv', '.json']
|
||||
});
|
||||
|
||||
router.get('/manifest', (req, res) => {
|
||||
const m = readManifest();
|
||||
const routeMap = m.routeMap || null;
|
||||
const fareTable = m.fareTable || null;
|
||||
res.json({
|
||||
ok: true,
|
||||
routeMap,
|
||||
fareTable,
|
||||
updatedAt: m.updatedAt || null
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
|
||||
const m = readManifest();
|
||||
m.routeMap = path.basename(req.file.filename);
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
|
||||
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
|
||||
const m = readManifest();
|
||||
m.fareTable = path.basename(req.file.filename);
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_fare_table', detail: { file: m.fareTable } });
|
||||
res.json({ ok: true, fareTable: m.fareTable, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.delete('/route-map', (req, res) => {
|
||||
const m = readManifest();
|
||||
if (m.routeMap) {
|
||||
try { fs.unlinkSync(path.join(ASSETS_DIR, m.routeMap)); } catch (e) {}
|
||||
}
|
||||
removeByPrefix('route-map');
|
||||
m.routeMap = null;
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/fare-table', (req, res) => {
|
||||
const m = readManifest();
|
||||
if (m.fareTable) {
|
||||
try { fs.unlinkSync(path.join(ASSETS_DIR, m.fareTable)); } catch (e) {}
|
||||
}
|
||||
removeByPrefix('fare-table');
|
||||
m.fareTable = null;
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_delete_fare_table', detail: { ok: true } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,160 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
|
||||
const AUDIO_DIR = path.join(__dirname, '../../Audio');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(AUDIO_DIR)) fs.mkdirSync(AUDIO_DIR, { recursive: true });
|
||||
|
||||
// Configure Multer
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Access target path from body (requires path field to be sent BEFORE file in FormData)
|
||||
const targetPath = req.body.path || '';
|
||||
const safePath = targetPath.replace(/\.\./g, '');
|
||||
const targetDir = path.join(AUDIO_DIR, safePath);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
cb(null, targetDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Decode latin1 to utf8 for original filename handling
|
||||
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||
cb(null, originalName);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
|
||||
// List Files
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const requestPath = req.query.path || '';
|
||||
const recursive = req.query.recursive === 'true';
|
||||
|
||||
// Prevent directory traversal
|
||||
if (requestPath.includes('..')) return res.status(400).json({ error: 'Invalid path' });
|
||||
|
||||
const targetDir = path.join(AUDIO_DIR, requestPath);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
// Recursive list for Lua client sync
|
||||
const getAllFiles = (dir, fileList = [], relativePath = '') => {
|
||||
const files = fs.readdirSync(dir);
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
const fileStat = fs.statSync(filePath);
|
||||
const fileRelativePath = path.join(relativePath, file).replace(/\\/g, '/');
|
||||
|
||||
if (fileStat.isDirectory()) {
|
||||
getAllFiles(filePath, fileList, fileRelativePath);
|
||||
} else {
|
||||
fileList.push({
|
||||
name: fileRelativePath,
|
||||
size: fileStat.size,
|
||||
mtime: fileStat.mtime,
|
||||
isDirectory: false
|
||||
});
|
||||
}
|
||||
});
|
||||
return fileList;
|
||||
};
|
||||
|
||||
const files = getAllFiles(AUDIO_DIR);
|
||||
return res.json(files);
|
||||
}
|
||||
|
||||
// Standard list for Web UI
|
||||
const items = fs.readdirSync(targetDir).map(name => {
|
||||
const p = path.join(targetDir, name);
|
||||
const stat = fs.statSync(p);
|
||||
return {
|
||||
name,
|
||||
path: path.join(requestPath, name).replace(/\\/g, '/'), // Relative path from AUDIO_DIR
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
isDirectory: stat.isDirectory()
|
||||
};
|
||||
});
|
||||
|
||||
// Sort directories first, then files
|
||||
items.sort((a, b) => {
|
||||
if (a.isDirectory === b.isDirectory) return a.name.localeCompare(b.name);
|
||||
return a.isDirectory ? -1 : 1;
|
||||
});
|
||||
|
||||
res.json(items);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create Folder
|
||||
router.post('/folder', (req, res) => {
|
||||
try {
|
||||
const { path: parentPath, name } = req.body;
|
||||
if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid folder name' });
|
||||
}
|
||||
|
||||
const safeParentPath = (parentPath || '').replace(/\.\./g, '');
|
||||
const newDirPath = path.join(AUDIO_DIR, safeParentPath, name);
|
||||
|
||||
if (fs.existsSync(newDirPath)) {
|
||||
return res.status(400).json({ error: 'Folder already exists' });
|
||||
}
|
||||
|
||||
fs.mkdirSync(newDirPath, { recursive: true });
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload File
|
||||
router.post('/', (req, res) => {
|
||||
const uploadMiddleware = upload.single('file');
|
||||
|
||||
uploadMiddleware(req, res, (err) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
// File is already saved to the correct directory by Multer diskStorage
|
||||
// No database storage needed as requested.
|
||||
const relativePath = path.relative(AUDIO_DIR, req.file.path).replace(/\\/g, '/');
|
||||
res.json({ ok: true, file: relativePath });
|
||||
});
|
||||
});
|
||||
|
||||
// Delete File or Folder
|
||||
router.delete('/', (req, res) => {
|
||||
const targetPath = req.query.path;
|
||||
if (!targetPath || targetPath.includes('..')) return res.status(400).json({ error: 'Invalid path' });
|
||||
|
||||
const p = path.join(AUDIO_DIR, targetPath);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(p);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} else {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user