From 7fea8807b83cb3612e8f3d4661b16c74176bca22 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 21 Jun 2026 10:37:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(web,installer):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=BA=90=E3=80=81=E5=8D=87=E7=BA=A7=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=BC=93=E5=AD=98=E7=89=88=E6=9C=AC=E3=80=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8C=96=E7=95=8C=E9=9D=A2=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 update_machine.lua 和 installer.lua 中的远程资源下载地址,从旧云存储链接切换为 Gitea 仓库提交镜像地址 - 新增双向闸机专用安装脚本 installer_bi.lua - 为所有网页HTML文件更新静态资源的缓存版本号,避免浏览器加载过期的静态文件缓存 - 修复登录页面的乱码文本,替换为标准简体中文内容,修正ICP备案标识文本 - 新增管理后台概览板块、快捷操作按钮,优化IC卡管理界面与响应式布局样式 --- gate.lua | 1288 +++++++++++++++++++++++++++++++++++++++ installer.lua | 4 +- installer_bi.lua | 164 +++++ update_machine.lua | 2 +- web/blog.html | 5 +- web/home.html | 5 +- web/ic-card-admin.html | 5 +- web/ic-card-detail.html | 5 +- web/ic-card-order.html | 5 +- web/ic-card-search.html | 5 +- web/index.html | 101 ++- web/index.js | 240 ++++++-- web/login.html | 39 +- web/style.css | 215 ++++++- web/ticket-board.html | 197 +++--- web/ticket-order.html | 5 +- web/ticket-route.html | 5 +- web/ticket-search.html | 5 +- web/token.html | 5 +- 19 files changed, 2095 insertions(+), 205 deletions(-) create mode 100644 gate.lua create mode 100644 installer_bi.lua diff --git a/gate.lua b/gate.lua new file mode 100644 index 0000000..20d2b45 --- /dev/null +++ b/gate.lua @@ -0,0 +1,1288 @@ +local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group" +local DEFAULT_SERVER_PATH = "/api/tickets/check" +local GATE_OPEN_SECONDS = 2 +local VERSION = "v1.5.7" + +local CONFIG_PATH = "gate_config.json" + +local function readFile(path) + if not fs.exists(path) then return nil end + local f = fs.open(path, "r") + if not f then return nil end + local c = f.readAll() + f.close() + return c +end + +local function writeFile(path, content) + local f = fs.open(path, "w") + if not f then return false end + f.write(content) + f.close() + return true +end + +local function trim(s) + return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function splitCsv(s) + local out = {} + s = trim(s) + if #s == 0 then return out end + for part in s:gmatch("[^,/%s]+") do + local v = trim(part) + if #v > 0 then table.insert(out, v) end + end + return out +end + +local pack = table.pack or function(...) + return { n = select("#", ...), ... } +end + +local function loadConfig() + local def = { mode = "entry", station_codes = {} } + local raw = readFile(CONFIG_PATH) + if not raw or #raw == 0 then return def end + local ok, data = pcall(textutils.unserializeJSON, raw) + if not ok or type(data) ~= "table" then return def end + if type(data.mode) == "string" then def.mode = data.mode end + if type(data.station_codes) == "table" then def.station_codes = data.station_codes end + if type(data.side_modes) == "table" then def.side_modes = data.side_modes end + if type(data.server_url) == "string" then def.server_url = data.server_url end + if type(data.card_server_url) == "string" then def.card_server_url = data.card_server_url end + if type(data.station_code) == "string" then def.station_code = data.station_code end + if type(data.side_station_codes) == "table" then def.side_station_codes = data.side_station_codes end + return def +end + +local function stationSetFromList(list) + local set = {} + if type(list) ~= "table" then return set end + for _, v in ipairs(list) do + local c = trim(v) + if #c > 0 then + local parts = splitCsv(c) + if #parts == 0 then + set[c] = true + else + for _, p in ipairs(parts) do set[p] = true end + end + end + end + return set +end + +local monitor = peripheral.find("monitor") +local speaker = peripheral.find("speaker") +local inspection = peripheral.find("ticket_inspection_machine") + +local serverConnected = nil +local serverLastChangeTs = 0 + +local function setServerConnected(ok) + if serverConnected == ok then return end + serverConnected = ok + serverLastChangeTs = os.epoch("utc") +end + +local termDev = term +if monitor then + pcall(monitor.setTextScale, 0.5) + termDev = monitor +end + +local function clear() + termDev.setBackgroundColor(colors.black) + termDev.setTextColor(colors.white) + termDev.clear() + termDev.setCursorPos(1, 1) +end + +local function centerText(y, text, color) + local w = termDev.getSize() + termDev.setTextColor(color or colors.white) + local x = math.max(1, math.floor((w - #text) / 2) + 1) + termDev.setCursorPos(x, y) + termDev.write(text) +end + +local function drawServerStatusIndicator(w) + if w < 2 then return end + local col = colors.yellow + if serverConnected == true then col = colors.lime + elseif serverConnected == false then col = colors.red end + termDev.setBackgroundColor(colors.black) + termDev.setTextColor(col) + termDev.setCursorPos(w - 1, 1) + termDev.write("S") + termDev.setCursorPos(w, 1) + termDev.write("*") + termDev.setTextColor(colors.white) +end + +local function drawVersionIndicator(w) + local s = tostring(VERSION or "") + if #s == 0 then return end + if w < #s then return end + termDev.setBackgroundColor(colors.black) + termDev.setTextColor(colors.gray) + termDev.setCursorPos(1, 1) + termDev.write(s) + termDev.setTextColor(colors.white) +end + +local function draw(statusLine1, statusLine2, statusColor) + clear() + local w, h = termDev.getSize() + centerText(1, "GATE", colors.cyan) + drawVersionIndicator(w) + drawServerStatusIndicator(w) + if statusLine1 and #statusLine1 > 0 then + centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white) + end + if statusLine2 and #statusLine2 > 0 then + centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white) + end + termDev.setCursorPos(1, h) + termDev.setTextColor(colors.gray) + termDev.write(string.rep(" ", w)) +end + +local function pulseLeftRedstone(seconds) + seconds = tonumber(seconds) or 1 + if not redstone or type(redstone.setOutput) ~= "function" then return end + pcall(redstone.setOutput, "left", true) + os.sleep(seconds) + pcall(redstone.setOutput, "left", false) +end + +pcall(function() + if redstone and type(redstone.setOutput) == "function" then + redstone.setOutput("left", false) + end +end) + +local function readApiEndpointFile(path) + local s = trim(readFile(path) or "") + if #s == 0 then return nil end + return s +end + +local function resolveServerURL(cfg) + if type(cfg.server_url) == "string" and #trim(cfg.server_url) > 0 then + local u = trim(cfg.server_url) + u = u:gsub("/api/tickets/status%s*$", "/api/tickets/check") + return u + end + + local base = readApiEndpointFile("API_ENDPOINT_GATE.txt") or readApiEndpointFile("API_ENDPOINT.txt") + if base and base:match("/api$") then + base = base:sub(1, -5) + end + if base and #base > 0 then + return base .. DEFAULT_SERVER_PATH + end + return DEFAULT_SERVER_BASE .. DEFAULT_SERVER_PATH +end + +local function guessBaseFromStatusURL(url) + url = trim(url or "") + if #url == 0 then return DEFAULT_SERVER_BASE end + local b = url:gsub("/api/tickets/check.*$", "") + b = b:gsub("/api/.*$", "") + b = trim(b) + if #b == 0 then return DEFAULT_SERVER_BASE end + return b +end + +local function httpRequest(method, url, body, headers) + if not http then + setServerConnected(false) + return false, "HTTP API disabled" + end + headers = headers or {} + local okReq, err = pcall(function() + http.request({ + url = url, + method = method, + headers = headers, + body = body, + }) + end) + if not okReq then + setServerConnected(false) + return false, tostring(err) + end + + while true do + local ev, p1, p2, p3 = os.pullEvent() + if ev == "http_success" and p1 == url then + local res = p2 + if type(res) == "table" and type(res.readAll) == "function" then + local data = res.readAll() + res.close() + setServerConnected(true) + return true, data + end + setServerConnected(false) + return false, "invalid http response" + end + if ev == "http_failure" and p1 == url then + local err = p2 + local res = p3 + if type(p2) == "table" and type(p2.readAll) == "function" then + res = p2 + err = p3 + end + if type(res) == "table" and type(res.readAll) == "function" then + local data = res.readAll() + res.close() + setServerConnected(false) + return false, data + end + setServerConnected(false) + return false, tostring(err or "http_failure") + end + os.queueEvent(ev, p1, p2, p3) + os.sleep(0) + end +end + +local function postCheck(url, payload) + local okBody, body = pcall(textutils.serializeJSON, payload) + if not okBody then return false end + local ok, data = httpRequest("POST", url, body, { ["Content-Type"] = "application/json" }) + if not ok then return false, data end + local okJ, parsed = pcall(textutils.unserializeJSON, data or "") + if not okJ then return false, data end + return true, parsed +end + +local function getJSON(url) + local ok, data = httpRequest("GET", url) + if not ok then return false, data end + local okJ, parsed = pcall(textutils.unserializeJSON, data or "") + if not okJ then return false, data end + return true, parsed +end + +local function resolveCardServerURL(cfg, ticketCheckURL) + if type(cfg.card_server_url) == "string" and #trim(cfg.card_server_url) > 0 then + return trim(cfg.card_server_url) + end + local base = guessBaseFromStatusURL(ticketCheckURL) + return base:gsub("/+$", "") .. "/api/cards/check" +end + +local function resolveCardSyncBaseURL(cfg, ticketCheckURL, cardCheckURL) + if type(cfg.card_sync_url) == "string" and #trim(cfg.card_sync_url) > 0 then + local raw = trim(cfg.card_sync_url) + return raw:gsub("/+$", "") + end + local base = guessBaseFromStatusURL(cardCheckURL or ticketCheckURL) + return base:gsub("/+$", "") .. "/api/ic-cards" +end + +local function resolveFareQueryURL(ticketCheckURL) + local base = guessBaseFromStatusURL(ticketCheckURL) + return base:gsub("/+$", "") .. "/api/public/fares/query" +end + +local function urlEncodeComponent(value) + local s = tostring(value or "") + return (s:gsub("([^%w%-_%.~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function toMoney(v) + local n = tonumber(v) + if n == nil then return nil end + return math.floor(n * 100 + 0.5) / 100 +end + +local stationNameToCode = {} + +local function normKey(s) + return trim(s):lower() +end + +local function refreshStationNameMap(serverBase) + serverBase = trim(serverBase or "") + if #serverBase == 0 then return false end + local url = serverBase:gsub("/+$", "") .. "/api/stations" + local ok, data = httpRequest("GET", url) + if not ok then return false end + local okJ, parsed = pcall(textutils.unserializeJSON, data or "") + if not okJ then return false end + if type(parsed) == "table" and type(parsed.stations) == "table" then + parsed = parsed.stations + end + if type(parsed) ~= "table" then return false end + + stationNameToCode = {} + for _, st in ipairs(parsed) do + if type(st) == "table" then + local code = trim(st.code) + if #code > 0 then + local en = trim(st.en_name or st.en) + if #en > 0 then stationNameToCode[normKey(en)] = code end + local cn = trim(st.name) + if #cn > 0 then stationNameToCode[normKey(cn)] = code end + end + end + end + return true +end + +local function inferStationCodeFromName(name) + local key = normKey(name or "") + if #key == 0 then return "" end + return stationNameToCode[key] or "" +end + +local function playDfpwm(path) + if not speaker then return end + if not fs.exists(path) then return end + local okD, dfpwm = pcall(require, "cc.audio.dfpwm") + if not okD or not dfpwm then return end + local h = fs.open(path, "rb") + if not h then return end + local decoder = dfpwm.make_decoder() + while true do + local chunk = h.read(16 * 1024) + if not chunk then break end + local buf = decoder(chunk) + while not speaker.playAudio(buf) do + os.pullEvent("speaker_audio_empty") + end + end + h.close() +end + +local function normalizeTicketId(v) + v = tostring(v or "") + v = v:gsub("^%s+", ""):gsub("%s+$", "") + v = v:gsub("%s+", "") + if #v == 0 then return nil end + local prefix, num = v: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 + elseif #num > 8 then + num = num:sub(-8) + end + return prefix .. "-" .. num + end + return v:lower() +end + +local function normalizeIcCardId(v) + local s = tostring(v or "") + s = s:gsub("%s+", ""):upper() + if #s == 0 then return "" end + local num = s:match("^IC%-?([0-9]+)$") + if num then + if #num < 6 then + num = string.rep("0", 6 - #num) .. num + elseif #num > 6 then + num = num:sub(-6) + end + return "IC-" .. num + end + return s +end + +local function collectScanTables(scan, includeTicket) + local out = {} + local seen = {} + local function add(v) + if type(v) ~= "table" or seen[v] then return end + seen[v] = true + table.insert(out, v) + end + if type(scan) ~= "table" then return out end + add(scan) + add(scan.data) + add(scan.payload) + add(scan.card) + add(scan.ic_card) + add(scan.wallet) + add(scan.card_data) + add(scan.media_data) + if includeTicket then + add(scan.ticket) + add(scan.ticket_data) + end + return out +end + +local function firstNonEmptyFromTables(tables, keys) + if type(tables) ~= "table" or type(keys) ~= "table" then return "" end + for _, t in ipairs(tables) do + for _, key in ipairs(keys) do + local v = t[key] + if v ~= nil then + local s = trim(v) + if #s > 0 then return s end + end + end + end + return "" +end + +local function firstNumberFromTables(tables, keys) + if type(tables) ~= "table" or type(keys) ~= "table" then return nil end + for _, t in ipairs(tables) do + for _, key in ipairs(keys) do + local n = tonumber(t[key]) + if n ~= nil then return n end + end + end + return nil +end + +local function isTruthy(v) + if v == true then return true end + if type(v) == "number" then return v ~= 0 end + if type(v) == "string" then + local s = v:lower() + return s == "true" or s == "1" or s == "yes" + end + return false +end + +local function firstTruthyFromTables(tables, keys) + if type(tables) ~= "table" or type(keys) ~= "table" then return false end + for _, t in ipairs(tables) do + for _, key in ipairs(keys) do + if t[key] ~= nil and isTruthy(t[key]) then + return true + end + end + end + return false +end + +local function getTicketId(scan) + local tables = collectScanTables(scan, true) + if #tables == 0 then return nil end + local raw = firstNonEmptyFromTables(tables, { + "ticketId", "ticket_id", "id", "ticketNo", "ticket_no", "code" + }) + if #raw == 0 then return nil end + return normalizeTicketId(raw) +end + + + +local function getCardId(scan) + local tables = collectScanTables(scan, false) + if #tables == 0 then return "" end + local raw = firstNonEmptyFromTables(tables, { + "card_id", + "cardId", + "ic_card_id", + "icCardId", + "wallet_id", + "walletId", + "card_uid", + "cardUid", + "uid", + "uuid", + "serial", + "serial_number", + "serialNumber", + "nfc_uid", + "nfcUid", + "rfid_uid", + "rfidUid" + }) + if #raw == 0 then return "" end + return normalizeIcCardId(raw) +end + +local function getCardBalance(scan) + local tables = collectScanTables(scan, false) + return firstNumberFromTables(tables, { + "balance", + "stored_value", + "storedValue", + "wallet_balance", + "walletBalance", + "remaining_balance", + "remainingBalance", + "value", + "amount" + }) +end + +local function isICCardScan(scan) + local tables = collectScanTables(scan, false) + if #tables == 0 then return false end + if #getCardId(scan) > 0 then return true end + if getCardBalance(scan) ~= nil then return true end + if type(scan.card) == "table" or type(scan.ic_card) == "table" or type(scan.wallet) == "table" then + return true + end + local media = firstNonEmptyFromTables(tables, { + "media", + "media_type", + "mediaType", + "product_type", + "productType", + "ticket_type", + "ticketType", + "kind", + "type", + "category" + }):lower() + if media:find("card", 1, true) or media:find("wallet", 1, true) then return true end + if media:find("ic", 1, true) or media:find("nfc", 1, true) or media:find("rfid", 1, true) then return true end + return false +end + +local function getStartStation(scan) + local tables = collectScanTables(scan, true) + local id = firstNonEmptyFromTables(tables, { + "entry", + "start_station", + "startStation", + "start", + "start_station_id", + "start_station_code", + "from_station", + "from", + "startStationId", + "start_stationId", + "entry_station", + "entryStation" + }) + if #id > 0 then return id end + return inferStationCodeFromName(firstNonEmptyFromTables(tables, { + "start_name_en", "startNameEn", "start_name", "fromNameCnU", "fromNameCn", "entry_name", "entryName" + })) +end + +local function getTerminalStation(scan) + local tables = collectScanTables(scan, true) + local id = firstNonEmptyFromTables(tables, { + "exit", + "terminal_station", + "terminalStation", + "terminal", + "end_station", + "endStation", + "terminal_station_id", + "terminal_station_code", + "to_station", + "to", + "endStationId", + "end_stationId", + "exit_station", + "exitStation" + }) + if #id > 0 then return id end + return inferStationCodeFromName(firstNonEmptyFromTables(tables, { + "terminal_name_en", "terminalNameEn", "terminal_name", "toNameCnU", "toNameCn", "exit_name", "exitName" + })) +end + + +local function saveLastScan(scan) + if type(scan) ~= "table" then return end + local t = {} + for k, v in pairs(scan) do t[k] = v end + local startStation = getStartStation(t) + if #startStation > 0 and (t.start_station == nil or trim(t.start_station) == "") then + t.start_station = startStation + end + local terminalStation = getTerminalStation(t) + if #terminalStation > 0 and (t.terminal_station == nil or trim(t.terminal_station) == "") then + t.terminal_station = terminalStation + end + + local ok, s = pcall(textutils.serializeJSON, t) + if not ok or type(s) ~= "string" then + ok, s = pcall(textutils.serialize, t) + end + if ok and type(s) == "string" then + writeFile("last_scan.json", s) + end +end + +local cfg = loadConfig() +local stationSet = stationSetFromList(cfg.station_codes) +local serverURL = resolveServerURL(cfg) +local cardServerURL = resolveCardServerURL(cfg, serverURL) +local cardSyncBaseURL = resolveCardSyncBaseURL(cfg, serverURL, cardServerURL) +local fareQueryURL = resolveFareQueryURL(serverURL) +local mode = (trim(cfg.mode):lower() == "exit") and "exit" or "entry" + +local modeBySide = nil +if type(cfg.side_modes) == "table" then + local tmp = {} + for side, m in pairs(cfg.side_modes) do + if type(side) == "string" then + local s = trim(side):lower() + if #s > 0 then + tmp[s] = (trim(m):lower() == "exit") and "exit" or "entry" + end + end + end + if next(tmp) ~= nil then modeBySide = tmp end +end + +local sideStationCodeBySide = nil +if type(cfg.side_station_codes) == "table" then + local tmp = {} + for side, code in pairs(cfg.side_station_codes) do + if type(side) == "string" then + local s = trim(side):lower() + local c = trim(code) + if #s > 0 and #c > 0 then + tmp[s] = c + end + end + end + if next(tmp) ~= nil then sideStationCodeBySide = tmp end +end + +pcall(function() + refreshStationNameMap(guessBaseFromStatusURL(serverURL)) +end) + +if not inspection then + if modeBySide == nil then + draw("Missing peripheral:", "ticket_inspection_machine", colors.red) + error("ticket_inspection_machine not found") + end +end + +if next(stationSet) == nil then + draw("No station codes set.", "Run installer first.", colors.red) + error("No station codes configured") +end + +local stationListText = table.concat(cfg.station_codes, ",") +local function readyLine1() + if not modeBySide then + return "Ready (" .. mode:upper() .. ")" + end + local f = modeBySide.front and modeBySide.front:upper() or "-" + local b = modeBySide.back and modeBySide.back:upper() or "-" + return "Ready (BI) F:" .. f .. " B:" .. b +end + +draw(readyLine1(), "Station: " .. stationListText, colors.lime) + +local stationCodesPayload = {} +for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end +table.sort(stationCodesPayload) + +local function trimSide(s) + s = trim(s or ""):lower() + if #s == 0 then return nil end + return s +end + +local function defaultStationCode() + local direct = trim(cfg.station_code or "") + if #direct > 0 then return direct end + return trim(stationCodesPayload[1] or "") +end + +local function stationCodeForSide(side) + side = trimSide(side) + if side and type(sideStationCodeBySide) == "table" then + local v = trim(sideStationCodeBySide[side] or "") + if #v > 0 then return v end + end + return defaultStationCode() +end + +local function isInspectionPeripheral(p) + return type(p) == "table" and ( + type(p.getLastScanned) == "function" + or type(p.updateICCard) == "function" + or type(p.updateTicket) == "function" + or type(p.destroyTicket) == "function" + ) +end + +local function resolveInspection(side) + side = trimSide(side) + if side and peripheral and type(peripheral.wrap) == "function" then + local okW, p = pcall(peripheral.wrap, side) + if okW and isInspectionPeripheral(p) then + return p + end + return nil + end + if isInspectionPeripheral(inspection) then return inspection end + return nil +end + +local function validateBidirectional() + if not modeBySide then return true end + for side, _ in pairs(modeBySide) do + if not resolveInspection(side) then + draw("Missing peripheral:", "ticket_inspection_machine@" .. tostring(side), colors.red) + error("ticket_inspection_machine not found on side: " .. tostring(side)) + end + end + return true +end + +validateBidirectional() + +local function inferSideFromScan(scan) + if type(scan) ~= "table" then return nil end + return trimSide( + scan.side + or scan.source_side + or scan.reader_side + or scan.peripheral_side + or scan.peripheralSide + or scan.device_side + or scan.peripheral + or scan.source + or scan.reader + or scan.name + ) +end + +local function isSideName(s) + s = trimSide(s) + if not s then return false end + return s == "front" or s == "back" or s == "left" or s == "right" or s == "top" or s == "bottom" +end + +local function parseTicketScannedArgsPacked(ev) + local side = nil + local scan = nil + if type(ev) ~= "table" then return nil, nil end + local n = tonumber(ev.n) or #ev + for i = 2, n do + local v = ev[i] + if not scan and type(v) == "table" then + scan = v + elseif not side and type(v) == "string" and isSideName(v) then + side = trimSide(v) + end + end + if scan and not side then side = inferSideFromScan(scan) end + return scan, side +end + +local function actionForSide(side) + if not modeBySide then return mode end + side = trimSide(side) + if not side then return mode end + return modeBySide[side] or mode +end + +local function collectInspectionDevices(side, modeBySideRef) + local sideKnown = trimSide(side) ~= nil + local inspectionDevs = {} + local function addDev(dev) + if not dev then return end + table.insert(inspectionDevs, dev) + end + if sideKnown then + addDev(resolveInspection(side)) + elseif modeBySideRef then + for s, _ in pairs(modeBySideRef) do + addDev(resolveInspection(s)) + end + else + addDev(resolveInspection(side)) + end + return inspectionDevs, sideKnown +end + +local function collectInspectionBindings(side, modeBySideRef, fallbackInspection) + local out = {} + local seen = {} + local function addBinding(sideName, dev) + if not dev or seen[dev] then return end + seen[dev] = true + table.insert(out, { side = trimSide(sideName), dev = dev }) + end + + side = trimSide(side) + if side then + addBinding(side, resolveInspection(side)) + elseif modeBySideRef then + for s, _ in pairs(modeBySideRef) do + addBinding(s, resolveInspection(s)) + end + else + addBinding(nil, fallbackInspection or resolveInspection(nil)) + end + return out +end + +local function updateDeviceField(dev, key, value) + if type(dev) ~= "table" or type(dev.updateTicket) ~= "function" then return end + pcall(dev.updateTicket, key, value) +end + +local function updateICCardField(dev, key, value) + if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false end + local okCall, okRes, detail = pcall(dev.updateICCard, key, value) + if not okCall then return false, tostring(okRes) end + if okRes == false then return false, tostring(detail or "update_failed") end + return true +end + +local function updateICCardFields(dev, patch) + local allOk = true + local firstErr = nil + if type(patch) ~= "table" then return false end + for key, value in pairs(patch) do + local okField, errField = updateICCardField(dev, key, value) + if not okField then + allOk = false + if not firstErr then + firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed") + end + end + end + return allOk, firstErr +end + +local function readLastScanned(dev) + if type(dev) ~= "table" or type(dev.getLastScanned) ~= "function" then return nil end + local ok, scan = pcall(dev.getLastScanned) + if not ok or type(scan) ~= "table" then return nil end + return scan +end + +local function getCardEntry(scan) + return firstNonEmptyFromTables(collectScanTables(scan, false), { + "entry", "entry_station", "entryStation", "start_station", "startStation" + }) +end + +local function getCardEntered(scan) + if #getCardEntry(scan) > 0 then return true end + return firstTruthyFromTables(collectScanTables(scan, false), { + "entered", "is_entered", "in_station", "inside_station" + }) +end + +local function getCardExited(scan) + if #getCardEntry(scan) > 0 then return false end + return firstTruthyFromTables(collectScanTables(scan, false), { + "exited", "is_exited", "out_station", "outside_station" + }) +end + +local function getCardOwnerName(scan) + return firstNonEmptyFromTables(collectScanTables(scan, false), { + "ownerName", "owner_name", "holder_name", "card_holder", "passenger" + }) +end + +local function queryFare(fromStation, toStation) + fromStation = trim(fromStation) + toStation = trim(toStation) + if #fromStation == 0 or #toStation == 0 then + return nil, "missing_station" + end + local url = fareQueryURL + .. "?from=" .. urlEncodeComponent(fromStation) + .. "&to=" .. urlEncodeComponent(toStation) + local ok, resp = getJSON(url) + if not ok or type(resp) ~= "table" then + return nil, "net_error" + end + local fare = tonumber( + resp.discounted_regular_fare + or resp.discounted_regular + or resp.regular_fare + or resp["优惠后常规票价"] + or resp["常规票价"] + or resp.regular + ) + if fare == nil then + return nil, tostring(resp.error or resp["错误"] or "fare_not_found") + end + return toMoney(fare), nil +end + +local function denyCard(reason, detail) + draw("DENIED", tostring(detail or reason or "deny"), colors.red) + playDfpwm("error.dfpwm") +end + +local function deductICCardBalance(dev, amount) + if type(dev) ~= "table" or type(dev.deductICCard) ~= "function" then + return false, "unsupported_method" + end + local okCall, okRes, detail = pcall(dev.deductICCard, amount) + if not okCall then return false, tostring(okRes) end + if okRes == true then return true, tonumber(detail) end + return false, tostring(detail or "deduct_failed") +end + +local function syncICCardState(cardId, payload) + local id = trim(cardId) + if #id == 0 then return false, "missing_card_id" end + local url = cardSyncBaseURL .. "/" .. urlEncodeComponent(id) .. "/sync" + payload = payload or {} + payload.card_id = id + return postCheck(url, payload) +end + +local function handleICCardScan(scan, side, scanDev) + saveLastScan(scan) + local inspectionDevs = scanDev and { scanDev } or select(1, collectInspectionDevices(side, modeBySide, inspection)) + local sideKnown = trimSide(side) ~= nil + local action = actionForSide(side) + if modeBySide and not sideKnown then + if getCardEntered(scan) and not getCardExited(scan) then + action = "exit" + else + action = "entry" + end + end + + local cardId = getCardId(scan) + if #cardId == 0 then + draw("Invalid card.", "Missing card_id.", colors.red) + playDfpwm("error.dfpwm") + return + end + + local balance = toMoney(getCardBalance(scan) or 0) or 0 + local entryStation = trim(getCardEntry(scan)) + local exitStation = stationCodeForSide(side) + local fare = 0 + local usedAction = action + + if #exitStation == 0 then + denyCard("missing_station", "Missing gate station") + return + end + + if usedAction == "entry" then + if getCardEntered(scan) and not getCardExited(scan) then + denyCard("already_entered", "Already entered") + return + end + local okWrite = true + local writeErr = nil + for _, dev in ipairs(inspectionDevs) do + local okPatch, errPatch = updateICCardFields(dev, { + entry = exitStation, + }) + okWrite = okPatch and okWrite + if not okPatch and not writeErr then writeErr = errPatch end + end + if not okWrite then + draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red) + playDfpwm("error.dfpwm") + return + end + entryStation = exitStation + else + if not getCardEntered(scan) then + denyCard("not_entered", "Not entered") + return + end + if getCardExited(scan) then + denyCard("already_exited", "Already exited") + return + end + if #entryStation == 0 then + denyCard("missing_entry_station", "Missing entry station") + return + end + local fareValue, fareErr = queryFare(entryStation, exitStation) + if fareValue == nil then + if fareErr == "net_error" then + draw("NET ERROR", "Fare lookup failed", colors.red) + playDfpwm("error.dfpwm") + else + denyCard("fare_not_found", fareErr) + end + return + end + fare = fareValue + if balance < fare then + denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance)) + return + end + local okWrite = true + local writeErr = nil + for _, dev in ipairs(inspectionDevs) do + local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare) + if okDeduct then + balance = toMoney(newBalanceOrErr or (balance - fare)) or 0 + local okClear, clearErr = updateICCardField(dev, "entry", nil) + local okFare, fareErr = updateICCardField(dev, "last_fare", fare) + if not okClear or not okFare then + okWrite = false + if not okClear and not writeErr then writeErr = "entry: " .. tostring(clearErr or "update_failed") end + if not okFare and not writeErr then writeErr = "last_fare: " .. tostring(fareErr or "update_failed") end + end + else + okWrite = false + if tostring(newBalanceOrErr) == "insufficient" then + denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance)) + else + draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red) + playDfpwm("error.dfpwm") + end + break + end + end + if not okWrite then + if writeErr then + draw("WRITE ERROR", tostring(writeErr), colors.red) + playDfpwm("error.dfpwm") + end + return + end + end + + for _, dev in ipairs(inspectionDevs) do + if usedAction == "entry" then + updateDeviceField(dev, "entered", true) + updateDeviceField(dev, "exited", false) + if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end + else + updateDeviceField(dev, "exited", true) + updateDeviceField(dev, "entered", false) + if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end + updateDeviceField(dev, "last_fare", fare) + end + if balance ~= nil then updateDeviceField(dev, "balance", balance) end + end + + local syncTs = (os.epoch and os.epoch("utc")) or (os.time() * 1000) + local okSync, syncResp = syncICCardState(cardId, { + type = "check", + action = usedAction, + device = "gate", + ts = syncTs, + station_code = exitStation, + entry_station = entryStation, + exit_station = usedAction == "exit" and exitStation or "", + entered = (usedAction == "entry"), + exited = (usedAction == "exit"), + fare = fare, + last_fare = fare, + balance = balance, + result = "pass" + }) + if okSync and type(syncResp) == "table" and type(syncResp.card) == "table" then + balance = toMoney(syncResp.card.balance or balance) or balance + end + + local line2 = nil + if usedAction == "exit" then + line2 = "-" .. tostring(fare) .. " Left: " .. tostring(balance or "?") + else + line2 = "Left: " .. tostring(balance or "?") + end + draw("PASS", line2, colors.lime) + parallel.waitForAll( + function() pulseLeftRedstone(GATE_OPEN_SECONDS) end, + function() playDfpwm("pass.dfpwm") end + ) +end + +local function handleScan(scan, side, scanDev) + saveLastScan(scan) + local inspectionDevs = scanDev and { scanDev } or select(1, collectInspectionDevices(side, modeBySide, inspection)) + local sideKnown = trimSide(side) ~= nil + local action = actionForSide(side) + if modeBySide and not sideKnown then + if isTruthy(scan and scan.entered) and not isTruthy(scan and scan.exited) then + action = "exit" + else + action = "entry" + end + end + + local ticketId = getTicketId(scan) + if not ticketId then + draw("Invalid ticket.", "Missing ticketId.", colors.red) + playDfpwm("error.dfpwm") + return + end + + local hintTripsTotal = tonumber(scan.trips_total or scan.rides_total or scan.trips or scan.rides) + local hintTripsRemaining = tonumber(scan.trips_remaining or scan.rides_remaining) + + local function doCheck(act) + return postCheck(serverURL, { + ticket_id = ticketId, + action = act, + station_codes = stationCodesPayload, + station_code = stationCodeForSide(side), + device = "gate", + ts = os.epoch("utc"), + trips_total = hintTripsTotal, + trips_remaining = hintTripsRemaining, + }) + end + + local ok, resp = doCheck(action) + local usedAction = action + if ok and type(resp) == "table" and resp.result ~= "pass" and tostring(resp.reason) == "wrong_station" and modeBySide and not sideKnown then + local alt = (action == "entry") and "exit" or "entry" + local ok2, resp2 = doCheck(alt) + if ok2 and type(resp2) == "table" then + if resp2.result == "pass" then + ok, resp = ok2, resp2 + usedAction = alt + elseif tostring(resp2.reason) ~= "wrong_station" then + ok, resp = ok2, resp2 + usedAction = alt + end + end + end + + if not ok or type(resp) ~= "table" then + draw("NET ERROR", "Server check failed.", colors.red) + playDfpwm("error.dfpwm") + return + end + + if resp.result ~= "pass" then + draw("DENIED", tostring(resp.reason or "deny"), colors.red) + playDfpwm("error.dfpwm") + return + end + + pcall(function() + if #inspectionDevs == 0 then return end + local newRides = tonumber(resp.trips_remaining) + or tonumber(scan.trips_remaining or scan.rides_remaining) + or tonumber(scan.rides) + for _, dev in ipairs(inspectionDevs) do + if type(dev) == "table" and type(dev.updateTicket) == "function" then + if usedAction == "entry" then + dev.updateTicket("entered", true) + dev.updateTicket("exited", false) + if newRides ~= nil then dev.updateTicket("rides", newRides) end + else + dev.updateTicket("exited", true) + dev.updateTicket("entered", false) + if newRides ~= nil then dev.updateTicket("rides", newRides) end + end + end + end + end) + + local remaining = tonumber(resp.trips_remaining) + if usedAction == "exit" and isTruthy(resp.destroy_ticket) and remaining ~= nil and remaining <= 0 then + for _, dev in ipairs(inspectionDevs) do + if type(dev) == "table" and type(dev.destroyTicket) == "function" then + pcall(dev.destroyTicket) + end + end + end + + local msg = (usedAction == "exit") + and ("Rides left: " .. tostring(resp.trips_remaining or "")) + or "Welcome." + + draw("PASS", msg, colors.lime) + parallel.waitForAll( + function() pulseLeftRedstone(GATE_OPEN_SECONDS) end, + function() playDfpwm("pass.dfpwm") end + ) +end + +local recentScans = {} + +local function buildScanKey(scan, side, eventName) + side = trimSide(side) or "-" + if eventName == "ic_card_scanned" or isICCardScan(scan) then + return table.concat({ + side, + "ic", + getCardId(scan), + tostring(getCardBalance(scan) or ""), + getCardEntry(scan), + getCardOwnerName(scan), + }, "|") + end + return table.concat({ + side, + "ticket", + tostring(getTicketId(scan) or ""), + tostring(scan.timestamp or scan.order_datetime or ""), + tostring(scan.rides or scan.trips or ""), + tostring(scan.entered or ""), + tostring(scan.exited or ""), + }, "|") +end + +local function shouldProcessScan(scan, side, eventName) + local key = buildScanKey(scan, side, eventName) + local now = os.epoch("utc") + local prev = recentScans[key] + recentScans[key] = now + if prev and (now - prev) < 500 then + return false + end + return true +end + +local function matchesEventType(scan, eventName) + if eventName == "ic_card_scanned" then return isICCardScan(scan) end + if eventName == "ticket_scanned" then return not isICCardScan(scan) end + return true +end + +local function processInspectionEvent(eventName, ev) + local payloadScan, payloadSide = parseTicketScannedArgsPacked(ev) + local bindings = collectInspectionBindings(payloadSide, modeBySide, inspection) + local handled = false + + for _, binding in ipairs(bindings) do + local scan = readLastScanned(binding.dev) + if type(scan) == "table" and matchesEventType(scan, eventName) and shouldProcessScan(scan, binding.side, eventName) then + if eventName == "ic_card_scanned" or isICCardScan(scan) then + handleICCardScan(scan, binding.side, binding.dev) + else + handleScan(scan, binding.side, binding.dev) + end + handled = true + end + end + + if not handled and type(payloadScan) == "table" and shouldProcessScan(payloadScan, payloadSide, eventName) then + local payloadDev = nil + if payloadSide then + payloadDev = resolveInspection(payloadSide) + elseif #bindings == 1 then + payloadDev = bindings[1].dev + elseif not modeBySide then + payloadDev = inspection + end + if eventName == "ic_card_scanned" or isICCardScan(payloadScan) then + handleICCardScan(payloadScan, payloadSide, payloadDev) + else + handleScan(payloadScan, payloadSide, payloadDev) + end + end +end + +while true do + local ev = pack(os.pullEvent()) + if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then + processInspectionEvent(ev[1], ev) + os.sleep(0.35) + draw(readyLine1(), "Station: " .. stationListText, colors.lime) + end +end diff --git a/installer.lua b/installer.lua index 1f53510..354783a 100644 --- a/installer.lua +++ b/installer.lua @@ -1,5 +1,5 @@ -local URL_ERROR = "http://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0" -local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0" +local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/error.dfpwm" +local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/pass.dfpwm" local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0" local CONFIG_PATH = "gate_config.json" diff --git a/installer_bi.lua b/installer_bi.lua new file mode 100644 index 0000000..5c44dfc --- /dev/null +++ b/installer_bi.lua @@ -0,0 +1,164 @@ +local URL_ERROR = "http://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0" +local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0" +local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0" + +local CONFIG_PATH = "gate_config.json" + +local function trim(s) + return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function splitCsv(s) + local out = {} + s = trim(s) + if #s == 0 then return out end + for part in s:gmatch("[^,/%s]+") do + local v = trim(part) + if #v > 0 then table.insert(out, v) end + end + return out +end + +local function writeFile(path, content, binary) + local mode = binary and "wb" or "w" + local f = fs.open(path, mode) + if not f then return false end + f.write(content) + f.close() + return true +end + +local function prompt(label) + term.write(label) + return trim(read() or "") +end + +local function promptOptionalUrl(label) + local raw = prompt(label) + raw = trim(raw) + if #raw == 0 then return nil end + return raw +end + +local function httpGet(url) + if not http then return false, "HTTP API disabled" end + local okReq, err = pcall(function() + http.request({ url = url, method = "GET" }) + end) + if not okReq then return false, tostring(err) end + + while true do + local ev, p1, p2, p3 = os.pullEvent() + if ev == "http_success" and p1 == url then + local res = p2 + if type(res) == "table" and type(res.readAll) == "function" then + local data = res.readAll() + res.close() + return true, data + end + return false, "invalid http response" + end + if ev == "http_failure" and p1 == url then + local err = p2 + local res = p3 + if type(p2) == "table" and type(p2.readAll) == "function" then + res = p2 + err = p3 + end + if type(res) == "table" and type(res.readAll) == "function" then + local data = res.readAll() + res.close() + return false, data + end + return false, tostring(err or "http_failure") + end + end +end + +local function download(url, path, binary) + print("Downloading: " .. path) + local ok, data = httpGet(url) + if not ok then + print("Download failed: " .. tostring(data or "")) + return false + end + if not writeFile(path, data, binary) then + print("Write failed: " .. path) + return false + end + return true +end + +local function normalizeMode(raw) + return (trim(raw):lower() == "exit") and "exit" or "entry" +end + +term.clear() +term.setCursorPos(1, 1) +print("Bidirectional Ticket Gate Installer") +print("") + +local stationsRaw = prompt("Station codes (comma or slash): ") +local stationCodes = splitCsv(stationsRaw) +if #stationCodes == 0 then + print("No station codes provided.") + return +end + +print("") +print("Set mode for each side (front/back).") +local frontRaw = prompt("Front mode (entry/exit): ") +local backRaw = prompt("Back mode (entry/exit): ") +local frontMode = normalizeMode(frontRaw) +local backMode = normalizeMode(backRaw) +local frontStationCode = trim(prompt("Front station code (default first code): ")) +local backStationCode = trim(prompt("Back station code (default first code): ")) +if #frontStationCode == 0 then frontStationCode = stationCodes[1] end +if #backStationCode == 0 then backStationCode = stationCodes[1] end + +print("") +print("Optional server config for ticket / IC card checks.") +local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ") +local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ") + +local cfg = { + station_codes = stationCodes, + station_code = stationCodes[1], + side_modes = { + front = frontMode, + back = backMode, + }, + side_station_codes = { + front = frontStationCode, + back = backStationCode, + } +} +if ticketServerUrl then cfg.server_url = ticketServerUrl end +if cardServerUrl then cfg.card_server_url = cardServerUrl end +local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg) +if not okCfg then + print("Config serialize failed.") + return +end +if not writeFile(CONFIG_PATH, cfgJson, false) then + print("Failed to write config.") + return +end + +if not download(URL_PASS, "pass.dfpwm", true) then return end +if not download(URL_ERROR, "error.dfpwm", true) then return end + +local okGate, gateCode = httpGet(URL_GATE) +if not okGate then + print("Download failed: startup") + return +end + +writeFile("startup.lua", gateCode, false) +writeFile("startup", gateCode, false) + +print("") +print("Done.") +print("This gate now supports tickets and IC cards.") +print("Attach ticket_inspection_machine on FRONT and BACK.") +print("Reboot the computer to start the gate.") diff --git a/update_machine.lua b/update_machine.lua index 900bb3e..0e577f5 100644 --- a/update_machine.lua +++ b/update_machine.lua @@ -1,5 +1,5 @@ -local URL_MACHINE = "http://cloud.fse-media.group/d/API/TicketMachine/ticketmachine.lua?sign=UIiheDcpyzwKdovDZM3G-8IRZqApFgU2Kpnhe9k5ETQ=:0" +local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/db1562b83045284bfdec9e4a3feb829193963943/ticketmachine.lua" local function writeFile(path, content, binary) local mode = binary and "wb" or "w" diff --git a/web/blog.html b/web/blog.html index 1f37c86..6a33198 100644 --- a/web/blog.html +++ b/web/blog.html @@ -6,7 +6,7 @@