feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化

- 升级售票机、检票机内置Lua脚本版本至v1.5.8
- 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本
- 前端新增版本管理配置页面,支持版本号配置和一键补丁升级
- 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记
- 简化installer配置交互流程,优化站点代码输入方式
- 重构后端配置规范化处理逻辑,统一配置初始化与存储流程
- 优化售票机外设检测、支付检测逻辑,修复部分已知问题
This commit is contained in:
2026-06-28 16:30:17 +08:00
parent 81debd3b55
commit 07e4200c17
9 changed files with 480 additions and 136 deletions
+286 -56
View File
@@ -1,6 +1,6 @@
local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'http://ticket.fse-media.group/api'
local VERSION = 'v1.5.7'
local VERSION = 'v1.5.8'
-- ###########################
-- Core HTTP & JSON Utilities
@@ -13,6 +13,17 @@ end
local serverConnected = nil
local serverLastChangeTs = 0
local expectedMachineVersion = nil
local versionMismatch = nil
local function normalizeVersionTag(v)
local s = tostring(v or ''):gsub('^%s+', ''):gsub('%s+$', '')
if #s == 0 then return '' end
if s:sub(1, 1):lower() ~= 'v' then
s = 'v' .. s
end
return s:lower()
end
local function setServerConnected(ok)
if serverConnected == ok then return end
@@ -314,22 +325,93 @@ end
-- ###########################
-- Peripheral discovery
-- ###########################
local monitor = peripheral.find('monitor')
local ticketVendingMachine = peripheral.find('ticket_vending_machine')
local speaker = peripheral.find('speaker')
local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 }
local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' }
local monitor = nil
local monitorName = nil
local ticketVendingMachine = nil
local ticketVendingMachineName = nil
local speaker = nil
local speakerName = nil
local detectedPaymentSide = nil
local MOD_DEBUG = true
pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())
local function safe(term)
if monitor then return peripheral.wrap(peripheral.getName(monitor)) end
return term
local function comparePeripheralName(a, b)
local pa = SIDE_PRIORITY[tostring(a or '')] or 99
local pb = SIDE_PRIORITY[tostring(b or '')] or 99
if pa ~= pb then return pa < pb end
return tostring(a or '') < tostring(b or '')
end
local termDev = safe(term)
if monitor then pcall(monitor.setTextScale, 0.5) end
local function peripheralTypeMatches(name, typeName)
if not peripheral or type(peripheral.getType) ~= 'function' then return false end
local got = peripheral.getType(name)
if type(got) == 'string' then return got == typeName end
if type(got) == 'table' then
for _, item in ipairs(got) do
if item == typeName then return true end
end
end
return false
end
local function findPeripheralByType(typeName)
if not peripheral then return nil, nil end
if type(peripheral.getNames) == 'function' and type(peripheral.wrap) == 'function' then
local names = peripheral.getNames() or {}
table.sort(names, comparePeripheralName)
for _, name in ipairs(names) do
if peripheralTypeMatches(name, typeName) then
local okWrap, dev = pcall(peripheral.wrap, name)
if okWrap and type(dev) == 'table' then
return dev, name
end
end
end
end
if type(peripheral.find) == 'function' then
local dev = peripheral.find(typeName)
if type(dev) == 'table' then
local name = nil
if type(peripheral.getName) == 'function' then
local okName, gotName = pcall(peripheral.getName, dev)
if okName then name = gotName end
end
return dev, name
end
end
return nil, nil
end
local termDev = term
local w, h = termDev.getSize()
local function refreshDevices()
local prevSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
monitor, monitorName = findPeripheralByType('monitor')
ticketVendingMachine, ticketVendingMachineName = findPeripheralByType('ticket_vending_machine')
speaker, speakerName = findPeripheralByType('speaker')
termDev = monitor or term
if monitor then pcall(monitor.setTextScale, 0.5) end
w, h = termDev.getSize()
local nextSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
if prevSignature ~= nextSignature then
os.queueEvent('config_updated')
end
end
refreshDevices()
local function saveCardIssueSnapshot(cardData)
pcall(function()
ensureDir('logs/last_card_issue.json')
@@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1)
return r1 ~= nil and r1 ~= false
end
local function getTicketVendingMachine()
refreshDevices()
if type(ticketVendingMachine) == 'table' then
return ticketVendingMachine
end
return nil
end
local function callPeripheralMethods(dev, methodNames, variants)
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
for _, methodName in ipairs(methodNames) do
@@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants)
end
local function issueBlankICCard(holderName, initialBalance)
local dev = ticketVendingMachine
local dev = getTicketVendingMachine()
if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
local safeHolderName = firstString(holderName, 'CARD USER')
local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
@@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance)
return false, '', 'unsupported_method'
end
local function writeICCard(cardData)
local dev = ticketVendingMachine
local function writeICCard(cardData, opts)
local dev = getTicketVendingMachine()
local options = opts or {}
local payload = {}
for k, v in pairs(cardData or {}) do payload[k] = v end
payload.media = payload.media or 'ic_card'
@@ -409,7 +500,9 @@ local function writeICCard(cardData)
saveCardIssueSnapshot(payload)
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
{ 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
options.writeOnly
and { 'writeCard', 'writeICCard', 'writeTicketData', 'issueTicketData' }
or { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
{
{ payload },
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
@@ -430,6 +523,75 @@ local function submitCardOpen(payload)
return postJSON(API_BASE .. '/cards/open', payload)
end
local function buildFinalCardData(payload, respData)
local data = (type(respData) == 'table') and respData or {}
return {
card_id = firstString(data.card_id, data.id, payload.card_id),
holder_name = payload.holder_name,
balance = firstNumber(data.balance, data.stored_value, payload.balance) or payload.balance,
deposit = firstNumber(data.deposit, payload.deposit) or payload.deposit,
topup = firstNumber(data.topup, data.first_topup, payload.topup) or payload.topup,
station_code = payload.station_code,
device = payload.device,
voucher_code = payload.voucher_code,
media = 'ic_card',
product_type = 'stored_value',
order_value = payload.order_value,
initial_balance = firstNumber(data.first_topup, data.topup, payload.topup, payload.balance) or payload.topup or payload.balance or 0
}
end
local function generateCardId()
local num = string.format('%06d', math.random(0, 999999))
return 'IC-' .. num
end
local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg, fallbackTicketId)
local dev = getTicketVendingMachine()
if type(dev) ~= 'table' then
return false, '', 'peripheral_unavailable'
end
local fn = dev.issueTicket
if type(fn) ~= 'function' then
return false, '', 'unsupported_method'
end
local function normalizeIssuedTicketId(id)
if id == nil then return '' end
local s = tostring(id):gsub('%s+', '')
if #s == 0 then return '' end
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
if prefix and num then
prefix = prefix:upper()
if #num < 8 then
num = string.rep('0', 8 - #num) .. num
end
return prefix .. '-' .. num
end
return s
end
local function tryIssue(...)
local okCall, r1, r2, r3 = pcall(fn, ...)
if not (okCall and peripheralCallSucceeded(r1)) then
return false, '', okCall and 'issue_failed' or 'issue_call_failed'
end
local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId)
local normalizedId = normalizeIssuedTicketId(issuedId)
if #normalizedId == 0 then
return false, '', 'invalid_ticket_id'
end
return true, normalizedId, 'issueTicket'
end
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
if okIssue then
return true, ticketId, 'issueTicket'
end
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
end
-- ###########################
-- Audio Utilities & Playback
-- ###########################
@@ -489,6 +651,15 @@ local function backgroundSyncTask()
end
end
local function backgroundPeripheralTask()
while true do
local ev = os.pullEvent()
if ev == 'peripheral' or ev == 'peripheral_detach' then
pcall(refreshDevices)
end
end
end
local function backgroundTicketUploadTask()
loadPendingUploadsOnce()
local backoff = 2
@@ -516,6 +687,17 @@ local stationByCode = {}
local adjacency_regular, adjacency_express = {}, {}
local transferGroupByCode = {}
local function updateVersionStateFromConfig()
local remote = normalizeVersionTag(type(CFG.lua_versions) == 'table' and CFG.lua_versions.ticketmachine or nil)
if #remote == 0 then
expectedMachineVersion = nil
versionMismatch = nil
return
end
expectedMachineVersion = remote
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
end
local function normalizeCode(s)
s = tostring(s or '')
s = s:gsub('[\239\187\191]', ''):gsub('%s+', '')
@@ -644,6 +826,7 @@ local function refreshConfigOnce()
if f then f.write(textutils.serializeJSON(cfg)); f.close() end
CFG = cfg
rebuildMaps()
updateVersionStateFromConfig()
os.queueEvent('config_updated')
return true
end
@@ -665,6 +848,7 @@ end
CFG = loadConfig() or CFG
rebuildMaps()
updateVersionStateFromConfig()
-- ###########################
@@ -889,10 +1073,20 @@ end
local function drawVersionIndicator()
if w < 1 then return end
local markerColor = colors.yellow
if versionMismatch == true then
markerColor = colors.red
elseif versionMismatch == false then
markerColor = colors.lime
end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1)
termDev.write(tostring(VERSION))
if w >= (#tostring(VERSION) + 1) then
termDev.setTextColor(markerColor)
termDev.write('*')
end
termDev.setTextColor(colors.white)
end
@@ -1457,6 +1651,33 @@ local function computeCost(src, dst, trainType)
return nil
end
local function paymentHintText()
if detectedPaymentSide and #tostring(detectedPaymentSide) > 0 then
return 'Payment side: ' .. tostring(detectedPaymentSide):upper()
end
return 'Insert payment on any side'
end
local function snapshotPaymentInputs()
local states = {}
if not redstone or type(redstone.getInput) ~= 'function' then return states end
for _, side in ipairs(REDSTONE_SIDES) do
states[side] = redstone.getInput(side) and true or false
end
return states
end
local function detectPaymentPulse(prevStates)
local nowStates = snapshotPaymentInputs()
for _, side in ipairs(REDSTONE_SIDES) do
if nowStates[side] and not prevStates[side] then
detectedPaymentSide = side
return true, side, nowStates
end
end
return false, nil, nowStates
end
local function drawOrder()
if state.productMode == 'card' then
local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card'
@@ -1536,7 +1757,7 @@ local function drawOrder()
if total <= 0 then
centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
else
centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray)
centerText(statusY + 1, paymentHintText(), colors.lightGray)
end
end
@@ -1603,6 +1824,8 @@ local function showOrderAndAudio()
local reuseBlankCardId = firstString(state.pendingBlankCardId)
if #reuseBlankCardId > 0 then
payload.card_id = reuseBlankCardId
else
payload.card_id = generateCardId()
end
local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
if #reuseBlankCardId == 0 then
@@ -1616,13 +1839,19 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
state.card_id = firstString(respData.card_id, respData.id, payload.card_id)
state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance
state.card_server_data = respData
state.pendingBlankCardId = nil
confirmed = true
statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
local finalCard = buildFinalCardData(payload, respData)
local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true })
if okWrite then
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData
state.pendingBlankCardId = nil
confirmed = true
statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
else
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
end
else
local errorMsg = 'Card API Err'
if code == 409 then
@@ -1640,20 +1869,7 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
local finalCard = {
card_id = firstString(respData.card_id, respData.id, payload.card_id),
holder_name = payload.holder_name,
balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance,
deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit,
topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup,
station_code = payload.station_code,
device = payload.device,
voucher_code = payload.voucher_code,
media = 'ic_card',
product_type = 'stored_value',
order_value = payload.order_value,
initial_balance = payload.topup
}
local finalCard = buildFinalCardData(payload, respData)
local okWrite, writtenCard, writeMethod = writeICCard(finalCard)
if okWrite then
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
@@ -1736,12 +1952,12 @@ local function showOrderAndAudio()
confirmAction()
end
local prev = redstone.getInput('right')
local prevInputs = snapshotPaymentInputs()
while state.page == 'order' do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'redstone' then
local now = redstone.getInput('right')
if now and not prev then
local pulsed, _, nextInputs = detectPaymentPulse(prevInputs)
if pulsed then
playNote('hat', 20, 1, 0.01)
state.paid = (state.paid or 0) + 1; render()
if state.paid >= (state.cost or 0) then
@@ -1752,7 +1968,8 @@ local function showOrderAndAudio()
sleep(0.5) -- Wait for UI/Audio slightly
confirmAction()
end
end; prev = now
end
prevInputs = nextInputs
elseif ev == 'mouse_click' or ev == 'monitor_touch' then
-- For mouse_click: p1=button, p2=x, p3=y
-- For monitor_touch: p1=side, p2=x, p3=y
@@ -1794,9 +2011,10 @@ local function generateTicketId()
end
local function ensureTicketIdFormat(id)
if id == nil then return generateTicketId() end
if id == nil then return '' end
local s = tostring(id)
s = s:gsub('%s+', '')
if #s == 0 then return '' end
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
if prefix and num then
prefix = prefix:upper()
@@ -1890,22 +2108,35 @@ local function showDone()
start_name = startObj and unicodeEscape(startObj.name) or nil,
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
start_name_en = fromNameEn,
terminal_name_en = toNameEn
terminal_name_en = toNameEn,
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
}
if ticketVendingMachine and ticketVendingMachine.issueTicket then
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
if not (okCall and okIssue and ticketId) then
okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
end
if okCall and okIssue and ticketId then
state.ticket_id = ensureTicketIdFormat(ticketId)
issueSource = 'ticket_vending_machine'
else
state.ticket_id = generateTicketId()
end
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
local localGeneratedTicketId = generateTicketId()
local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral(
fromNameEnArg,
toNameEnArg,
apiType,
rides,
cost,
startStationArg,
terminalStationArg,
fromNameCnUArg,
toNameCnUArg,
localGeneratedTicketId
)
if okIssueTicket then
state.ticket_id = issuedTicketId
issueSource = 'ticket_vending_machine'
else
state.ticket_id = generateTicketId()
local issueError = tostring(issueMethod or 'ticket_issue_failed')
print('Ticket issue failed: ' .. issueError)
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
showAlert('Ticket issue failed')
resetTicketFlow()
state.page = 'home'
return
end
pcall(function()
@@ -2074,7 +2305,6 @@ local function showOnlineVoucher()
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
elseif ev == 'config_updated' then
-- Config updated in background, continue to redraw
end
end
end
@@ -2101,4 +2331,4 @@ local function mainPageLoop()
end
end
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)