feat(ticket): 新增单程票购票与检票功能,优化日志与接口

- 前端新增单程票购票界面,支持票种切换与区间选择、价格预览
- 完善后端检票接口,添加单程票的校验与处理逻辑
- 优化售票机脚本的车票类型映射,新增单程车票支持
- 简化前端售票日志输出,移除冗余站点信息字段
- 新增服务层付费区间计算字段,优化公共接口返回数据
This commit is contained in:
2026-07-10 00:08:31 +08:00
parent 6098590692
commit fc4fe5101c
9 changed files with 246 additions and 54 deletions
+23 -9
View File
@@ -1336,18 +1336,23 @@ router.post('/tickets/check', async (req, res) => {
toFiniteNumberOrUndef(hintTripsRemaining);
const tripsRemainEffective = (tripsRemain0 == null) ? tripsTotal : tripsRemain0;
const isSingleTicket = String(cur.train_type || '').toLowerCase() === 'single';
if (action === 'entry') {
if (!matchesStation(start)) return deny('wrong_station');
if (!isSingleTicket && !matchesStation(start)) return deny('wrong_station');
if (entered && !exited) return deny('already_entered');
const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: start, station_name: resolveStationName(start), device };
const entryCode = isSingleTicket ? (station_code_raw || start) : start;
const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: entryCode, station_name: resolveStationName(entryCode), device };
await DataService.appendTicketEvent(ev);
await DataService.upsertTicketIndex({
ticket_id,
entered: true,
exited: false,
last_action: 'entry',
last_station_code: start,
last_station_code: entryCode,
start: isSingleTicket ? entryCode : cur.start,
last_result: 'pass',
last_reason: '',
last_event: 'check',
@@ -1357,22 +1362,31 @@ router.post('/tickets/check', async (req, res) => {
});
io.emit('ticket:check', ev);
appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev });
return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: start, trips_remaining: tripsRemainEffective, destroy_ticket: false });
return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: entryCode, trips_remaining: tripsRemainEffective, destroy_ticket: false });
}
const exitCode = station_code_raw || terminal;
if (isSingleTicket) {
const fareInfo = LogicService.computeFareBoth(cur.start, exitCode);
if (!fareInfo || fareInfo.paid_segments !== 1) {
return deny('非单区间');
}
} else {
if (!matchesStation(terminal)) return deny('wrong_station');
}
if (!matchesStation(terminal)) return deny('wrong_station');
if (!entered) return deny('not_entered');
if (exited) return deny('already_exited');
const newTripsRemaining = Math.max(0, (Number(tripsRemainEffective) || 0) - 1);
const shouldSetUsed = newTripsRemaining <= 0;
const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: terminal, station_name: resolveStationName(terminal), device, trips_remaining: newTripsRemaining };
const shouldSetUsed = isSingleTicket ? true : (newTripsRemaining <= 0);
const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: exitCode, station_name: resolveStationName(exitCode), device, trips_remaining: newTripsRemaining };
await DataService.appendTicketEvent(ev);
await DataService.upsertTicketIndex({
ticket_id,
exited: true,
last_action: 'exit',
last_station_code: terminal,
last_station_code: exitCode,
last_result: 'pass',
last_reason: '',
last_event: 'check',
@@ -1383,7 +1397,7 @@ router.post('/tickets/check', async (req, res) => {
});
io.emit('ticket:check', ev);
appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev });
return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: terminal, trips_remaining: newTripsRemaining, destroy_ticket: shouldSetUsed });
return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: exitCode, trips_remaining: newTripsRemaining, destroy_ticket: shouldSetUsed });
});
router.post('/tickets/status', async (req, res) => {
+8 -2
View File
@@ -714,13 +714,19 @@ router.get('/tickets/:id', async (req, res, next) => {
action: e.action,
ts: e.ts,
ticket_id: e.ticket_id,
station_name: nameFor(e.station_code),
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 || ''),
trips_remaining: e.trips_remaining ?? e.rides_remaining ?? null
};
} else if (e.type === 'check') {
return {
...e,
station_name: e.station_name || nameFor(e.station_code),
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || '')
};
}
return { raw: e };
return e;
});
appendReqLog(req, { category: 'public', type: 'ticket_detail', detail: { ticket_id: id, ok: true } });
+4 -1
View File
@@ -231,7 +231,8 @@ const LogicService = {
regular_path: [src],
express_path: [src],
regular_transfers: [],
express_transfers: []
express_transfers: [],
paid_segments: 0
};
}
@@ -245,6 +246,7 @@ const LogicService = {
const totals = accumulateFareAlongPath(graph, result.path);
if (!totals) return null;
const transfers = computeTransfersAlongPath(graph, result.path);
const dstCost = result.dist.get(dst);
return {
regular: Math.round(Number(totals.regular ?? 0) || 0),
@@ -253,6 +255,7 @@ const LogicService = {
express_path: result.path,
regular_transfers: transfers,
express_transfers: transfers,
paid_segments: dstCost ? dstCost.paidSegments : Infinity
};
} catch (_) { return null; }
},