2026-06-21 10:00:13 +08:00
const mysql = require ( 'mysql2/promise' ) ;
const path = require ( 'path' ) ;
const fs = require ( 'fs' ) ;
const DATA _DIR = path . join ( _ _dirname , '../../web/data' ) ;
// Ensure data directory exists (for export.json)
if ( ! fs . existsSync ( DATA _DIR ) ) fs . mkdirSync ( DATA _DIR , { recursive : true } ) ;
const paths = {
export : path . join ( DATA _DIR , 'export.json' )
} ;
const pool = mysql . createPool ( {
host : '127.0.0.1' ,
port : 3306 ,
user : 'cc-ticket-machine' ,
password : 'cc-ticket-machine' ,
database : 'cc-ticket-machine' ,
waitForConnections : true ,
connectionLimit : 10 ,
queueLimit : 0
} ) ;
2026-06-28 16:30:17 +08:00
const DEFAULT _LUA _VERSIONS = {
ticketmachine : 'v1.5.8' ,
gate : 'v1.5.8'
} ;
function normalizeLuaVersions ( input ) {
const src = ( input && typeof input === 'object' ) ? input : { } ;
return {
ticketmachine : String ( src . ticketmachine || DEFAULT _LUA _VERSIONS . ticketmachine ) ,
gate : String ( src . gate || DEFAULT _LUA _VERSIONS . gate )
} ;
}
function normalizeConfig ( input ) {
const src = ( input && typeof input === 'object' ) ? input : { } ;
return {
... src ,
api _base : String ( src . api _base || 'http://127.0.0.1:23333/api' ) ,
current _station : ( src . current _station && typeof src . current _station === 'object' )
? src . current _station
: { name : 'Station1' , code : '01-01' } ,
transfers : Array . isArray ( src . transfers ) ? src . transfers : [ ] ,
promotion : {
name : String ( src ? . promotion ? . name || '' ) ,
discount : Number ( src ? . promotion ? . discount ? ? 1 )
} ,
lua _versions : normalizeLuaVersions ( src . lua _versions )
} ;
}
2026-06-21 10:00:13 +08:00
// In-memory cache for synchronous read access
const cache = {
2026-06-28 16:30:17 +08:00
config : normalizeConfig ( { } ) ,
2026-06-21 10:00:13 +08:00
stations : [ ] ,
lines : [ ] ,
fares : [ ] ,
orders : [ ] ,
orderIndex : { } ,
ticketIndex : { } ,
icCards : [ ] ,
icCardIndex : { } ,
icCardEvents : [ ] ,
statsTicket : [ ] ,
statsGate : [ ]
} ;
const DataService = {
paths , // Kept for compatibility if anything accesses paths.export
init : async ( ) => {
try {
// Create Tables
const conn = await pool . getConnection ( ) ;
try {
await conn . query ( ` CREATE TABLE IF NOT EXISTS kv_store (k VARCHAR(255) PRIMARY KEY, v JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS stations (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS \` lines \` (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS fares (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS orders (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS order_index (order_code VARCHAR(255) PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS ticket_index (ticket_id VARCHAR(255) PRIMARY KEY, data JSON, last_update_ts BIGINT) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS ic_cards (card_id VARCHAR(255) PRIMARY KEY, data JSON, last_update_ts BIGINT) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS ic_card_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS logs (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
await conn . query ( ` CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON) ` ) ;
// Load Cache
const [ configs ] = await conn . query ( 'SELECT v FROM kv_store WHERE k = ?' , [ 'config' ] ) ;
2026-06-28 16:30:17 +08:00
if ( configs . length > 0 ) cache . config = normalizeConfig ( configs [ 0 ] . v ) ;
2026-06-21 10:00:13 +08:00
else await conn . query ( 'INSERT INTO kv_store (k, v) VALUES (?, ?)' , [ 'config' , JSON . stringify ( cache . config ) ] ) ;
const [ stations ] = await conn . query ( 'SELECT data FROM stations' ) ;
cache . stations = stations . map ( r => r . data ) ;
const [ lines ] = await conn . query ( 'SELECT data FROM `lines`' ) ;
cache . lines = lines . map ( r => r . data ) ;
const [ fares ] = await conn . query ( 'SELECT data FROM fares' ) ;
cache . fares = fares . map ( r => r . data ) ;
const [ orders ] = await conn . query ( 'SELECT data FROM orders' ) ;
cache . orders = orders . map ( r => r . data ) ;
const [ orderIndices ] = await conn . query ( 'SELECT order_code, data FROM order_index' ) ;
orderIndices . forEach ( r => { cache . orderIndex [ r . order _code ] = r . data ; } ) ;
const [ ticketIndices ] = await conn . query ( 'SELECT ticket_id, data FROM ticket_index' ) ;
ticketIndices . forEach ( r => { cache . ticketIndex [ r . ticket _id ] = r . data ; } ) ;
const [ icCards ] = await conn . query ( 'SELECT card_id, data FROM ic_cards' ) ;
cache . icCardIndex = { } ;
icCards . forEach ( r => { cache . icCardIndex [ r . card _id ] = r . data ; } ) ;
cache . icCards = Object . values ( cache . icCardIndex ) . sort ( ( a , b ) => Number ( b . last _update _ts || 0 ) - Number ( a . last _update _ts || 0 ) ) ;
const [ icCardEvents ] = await conn . query ( 'SELECT data FROM ic_card_events ORDER BY id ASC' ) ;
cache . icCardEvents = icCardEvents . map ( ( r ) => r . data ) ;
const [ statsT ] = await conn . query ( 'SELECT data FROM stats_ticket' ) ;
cache . statsTicket = statsT . map ( r => r . data ) ;
const [ statsG ] = await conn . query ( 'SELECT data FROM stats_gate' ) ;
cache . statsGate = statsG . map ( r => r . data ) ;
console . log ( 'DataService initialized with MySQL' ) ;
} finally {
conn . release ( ) ;
}
} catch ( e ) {
console . error ( 'Failed to initialize DataService:' , e ) ;
// Fallback or exit? For now, we continue but cache might be empty
}
} ,
// Config
getConfig : ( ) => cache . config ,
saveConfig : async ( cfg ) => {
2026-06-28 16:30:17 +08:00
cache . config = normalizeConfig ( cfg ) ;
await pool . query ( 'INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?' , [ 'config' , JSON . stringify ( cache . config ) , JSON . stringify ( cache . config ) ] ) ;
2026-06-21 10:00:13 +08:00
} ,
// Stations
getStations : ( ) => cache . stations ,
saveStations : async ( list ) => {
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM stations' ) ;
if ( list . length > 0 ) {
const placeholders = list . map ( ( ) => '(?)' ) . join ( ',' ) ;
const values = list . map ( item => JSON . stringify ( item ) ) ;
await conn . query ( 'INSERT INTO stations (data) VALUES ' + placeholders , values ) ;
}
await conn . commit ( ) ;
cache . stations = list ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveStations error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
// Lines
getLines : ( ) => cache . lines ,
saveLines : async ( list ) => {
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM `lines`' ) ;
if ( list . length > 0 ) {
const placeholders = list . map ( ( ) => '(?)' ) . join ( ',' ) ;
const values = list . map ( item => JSON . stringify ( item ) ) ;
await conn . query ( 'INSERT INTO `lines` (data) VALUES ' + placeholders , values ) ;
}
await conn . commit ( ) ;
cache . lines = list ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveLines error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
// Fares
getFares : ( ) => cache . fares ,
saveFares : async ( list ) => {
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM fares' ) ;
if ( list . length > 0 ) {
const placeholders = list . map ( ( ) => '(?)' ) . join ( ',' ) ;
const values = list . map ( item => JSON . stringify ( item ) ) ;
await conn . query ( ` INSERT INTO fares (data) VALUES ${ placeholders } ` , values ) ;
}
await conn . commit ( ) ;
cache . fares = list ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveFares error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
// Orders
getOrders : ( ) => cache . orders ,
saveOrders : async ( list ) => {
// Optimization: If we assume append-only, we can just insert the last one.
// But the API passes the whole list. For correctness with the current API:
// Full replace is inefficient for orders. But mimicking file overwrite.
// Better: logic.js pushes to list and calls saveOrders.
// We should probably just insert the new ones if we could track diffs.
// For now, doing full replace to be safe with existing logic, or optimized if possible.
// Wait, the API `router.post('/orders')` does: list.push(rec); saveOrders(list);
// I can optimize this in `saveOrders` if I knew it was an append.
// But to be safe and simple:
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM orders' ) ;
if ( list . length > 0 ) {
// Split into chunks if too large?
const placeholders = list . map ( ( ) => '(?)' ) . join ( ',' ) ;
const values = list . map ( item => JSON . stringify ( item ) ) ;
// Be careful with max packet size.
// If list is huge, this crashes.
// But for this task, we assume reasonable size.
await conn . query ( ` INSERT INTO orders (data) VALUES ${ placeholders } ` , values ) ;
}
await conn . commit ( ) ;
cache . orders = list ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveOrders error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
getOrderIndex : ( ) => cache . orderIndex ,
saveOrderIndex : async ( idx ) => {
// This is also potentially huge.
// `router.post('/orders')` does: idx[code] = rec; saveOrderIndex(idx);
// Ideally we should just upsert the single entry.
// But since we receive the whole object, we can't easily know which one changed without diffing.
// However, for the sake of the migration task, I will do a truncate/insert loop or similar.
// Actually, `order_index` table has `order_code` PK.
// I can iterate and UPSERT all? That's slow.
// Given "Development" context, maybe I just clear and insert all.
// OR, I can accept that `saveOrderIndex` is heavy.
// Better approach: modifying `router.post('/orders')` to NOT call saveOrderIndex with the whole object, but call a new method `addOrder(rec)`.
// But I want to minimize changes to `api.js`.
// Let's implement full save for now.
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM order_index' ) ;
const entries = Object . entries ( idx ) ;
if ( entries . length > 0 ) {
// Batch insert
for ( let i = 0 ; i < entries . length ; i += 100 ) {
const batch = entries . slice ( i , i + 100 ) ;
const q = ` INSERT INTO order_index (order_code, data) VALUES ${ batch . map ( ( ) => '(?,?)' ) . join ( ',' ) } ` ;
const params = batch . flatMap ( ( [ k , v ] ) => [ k , JSON . stringify ( v ) ] ) ;
await conn . query ( q , params ) ;
}
}
await conn . commit ( ) ;
cache . orderIndex = idx ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveOrderIndex error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
// IC Cards
getIcCards : ( ) => cache . icCards ,
getIcCardIndex : ( ) => cache . icCardIndex ,
saveIcCards : async ( list ) => {
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM ic_cards' ) ;
if ( list . length > 0 ) {
for ( let i = 0 ; i < list . length ; i += 100 ) {
const batch = list . slice ( i , i + 100 ) . map ( ( item ) => ( {
... item ,
card _id : String ( item ? . card _id || '' ) . trim ( ) ,
last _update _ts : Number ( item ? . last _update _ts || Date . now ( ) )
} ) ) . filter ( ( item ) => item . card _id ) ;
if ( batch . length === 0 ) continue ;
const q = ` INSERT INTO ic_cards (card_id, data, last_update_ts) VALUES ${ batch . map ( ( ) => '(?,?,?)' ) . join ( ',' ) } ` ;
const params = batch . flatMap ( ( item ) => [ item . card _id , JSON . stringify ( item ) , item . last _update _ts ] ) ;
await conn . query ( q , params ) ;
}
}
await conn . commit ( ) ;
cache . icCardIndex = { } ;
list . forEach ( ( item ) => {
const id = String ( item ? . card _id || '' ) . trim ( ) ;
if ( id ) cache . icCardIndex [ id ] = item ;
} ) ;
cache . icCards = Object . values ( cache . icCardIndex ) . sort ( ( a , b ) => Number ( b . last _update _ts || 0 ) - Number ( a . last _update _ts || 0 ) ) ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveIcCards error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
upsertIcCard : async ( update ) => {
const id = String ( update ? . card _id || '' ) . trim ( ) ;
if ( ! id ) return null ;
const cur = cache . icCardIndex [ id ] || { } ;
const merged = { ... cur , ... update , card _id : id , last _update _ts : Date . now ( ) } ;
cache . icCardIndex [ id ] = merged ;
cache . icCards = Object . values ( cache . icCardIndex ) . sort ( ( a , b ) => Number ( b . last _update _ts || 0 ) - Number ( a . last _update _ts || 0 ) ) ;
try {
await pool . query (
'INSERT INTO ic_cards (card_id, data, last_update_ts) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE data = ?, last_update_ts = ?' ,
[ id , JSON . stringify ( merged ) , merged . last _update _ts , JSON . stringify ( merged ) , merged . last _update _ts ]
) ;
} catch ( e ) {
console . error ( 'upsertIcCard error' , e ) ;
}
return merged ;
} ,
deleteIcCard : async ( cardId ) => {
const id = String ( cardId || '' ) . trim ( ) ;
if ( ! id ) return ;
delete cache . icCardIndex [ id ] ;
cache . icCards = Object . values ( cache . icCardIndex ) . sort ( ( a , b ) => Number ( b . last _update _ts || 0 ) - Number ( a . last _update _ts || 0 ) ) ;
try {
await pool . query ( 'DELETE FROM ic_cards WHERE card_id = ?' , [ id ] ) ;
} catch ( e ) {
console . error ( 'deleteIcCard error' , e ) ;
}
} ,
appendIcCardEvent : async ( entry ) => {
cache . icCardEvents . push ( entry ) ;
try {
await pool . query ( 'INSERT INTO ic_card_events (data) VALUES (?)' , [ JSON . stringify ( entry ) ] ) ;
} catch ( e ) {
console . error ( 'appendIcCardEvent error' , e ) ;
}
} ,
getIcCardEvents : async ( cardId ) => {
const id = String ( cardId || '' ) . trim ( ) ;
try {
return cache . icCardEvents . filter ( ( item ) => String ( item ? . card _id || '' ) . trim ( ) === id ) ;
} catch ( e ) {
console . error ( 'getIcCardEvents error' , e ) ;
return [ ] ;
}
} ,
// Logs (Async Read, Append Write)
appendLog : async ( entry ) => {
try {
await pool . query ( 'INSERT INTO logs (data) VALUES (?)' , [ JSON . stringify ( entry ) ] ) ;
} catch ( e ) { console . error ( 'appendLog error' , e ) ; }
} ,
readLogs : async ( { max = 200 , category , type , q , since , until } = { } ) => {
try {
const limit = Math . min ( 5000 , Math . max ( 1 , Number ( max ) || 200 ) ) ;
const fetchLimit = Math . min ( 20000 , Math . max ( limit , limit * 10 ) ) ;
const [ rows ] = await pool . query ( 'SELECT data FROM logs ORDER BY id DESC LIMIT ?' , [ fetchLimit ] ) ;
let list = rows . map ( r => r . data ) ;
const cat = ( category == null ) ? '' : String ( category ) . trim ( ) . toLowerCase ( ) ;
if ( cat ) list = list . filter ( x => String ( x ? . category || '' ) . trim ( ) . toLowerCase ( ) === cat ) ;
const typeRaw = ( type == null ) ? '' : String ( type ) . trim ( ) ;
if ( typeRaw ) {
const typeList = typeRaw . split ( ',' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) . map ( s => s . toLowerCase ( ) ) ;
list = list . filter ( x => typeList . includes ( String ( x ? . type || '' ) . trim ( ) . toLowerCase ( ) ) ) ;
}
const qRaw = ( q == null ) ? '' : String ( q ) . trim ( ) . toLowerCase ( ) ;
if ( qRaw ) {
list = list . filter ( x => {
try { return JSON . stringify ( x || { } ) . toLowerCase ( ) . includes ( qRaw ) ; } catch ( e ) { return false ; }
} ) ;
}
const toTs = ( v ) => {
if ( v == null ) return null ;
if ( typeof v === 'number' && Number . isFinite ( v ) ) return v ;
const s = String ( v ) . trim ( ) ;
if ( ! s ) return null ;
const n = Number ( s ) ;
if ( Number . isFinite ( n ) ) return n ;
const d = Date . parse ( s ) ;
return Number . isFinite ( d ) ? d : null ;
} ;
const sinceTs = toTs ( since ) ;
const untilTs = toTs ( until ) ;
if ( sinceTs != null || untilTs != null ) {
list = list . filter ( x => {
const d = Date . parse ( String ( x ? . ts || '' ) ) ;
if ( ! Number . isFinite ( d ) ) return false ;
if ( sinceTs != null && d < sinceTs ) return false ;
if ( untilTs != null && d > untilTs ) return false ;
return true ;
} ) ;
}
return list . slice ( 0 , limit ) ;
} catch ( e ) {
return [ ] ;
}
} ,
readLastLogs : async function ( max = 200 ) { return this . readLogs ( { max } ) ; } ,
// Ticket Events (Async Read, Append Write)
appendTicketEvent : async ( ev ) => {
try {
await pool . query ( 'INSERT INTO ticket_events (data) VALUES (?)' , [ JSON . stringify ( ev ) ] ) ;
} catch ( e ) { console . error ( 'appendTicketEvent error' , e ) ; }
} ,
readAllTicketEvents : async ( ) => { // Changed to async!
try {
const [ rows ] = await pool . query ( 'SELECT data FROM ticket_events ORDER BY id ASC' ) ;
return rows . map ( r => r . data ) ;
} catch ( e ) { return [ ] ; }
} ,
// Optimized method for filtering by ticket_id (if I update callers)
getTicketEvents : async ( ticketId ) => {
try {
// We can't easily filter JSON in WHERE clause efficiently without generated columns.
// But for small scale it's fine. Or fetch all and filter in app (like original).
// `SELECT data FROM ticket_events WHERE data->>"$.ticket_id" = ?`
const [ rows ] = await pool . query ( 'SELECT data FROM ticket_events WHERE data->"$.ticket_id" = ? ORDER BY id ASC' , [ ticketId ] ) ;
return rows . map ( r => r . data ) ;
} catch ( e ) { return [ ] ; }
} ,
// Ticket Index
getTicketIndex : ( ) => cache . ticketIndex ,
saveTicketIndex : async ( idx ) => {
// Same issue as orderIndex. Heavy.
const conn = await pool . getConnection ( ) ;
try {
await conn . beginTransaction ( ) ;
await conn . query ( 'DELETE FROM ticket_index' ) ;
const entries = Object . entries ( idx ) ;
if ( entries . length > 0 ) {
for ( let i = 0 ; i < entries . length ; i += 100 ) {
const batch = entries . slice ( i , i + 100 ) ;
const q = ` INSERT INTO ticket_index (ticket_id, data, last_update_ts) VALUES ${ batch . map ( ( ) => '(?,?,?)' ) . join ( ',' ) } ` ;
const params = batch . flatMap ( ( [ k , v ] ) => [ k , JSON . stringify ( v ) , v . last _update _ts || 0 ] ) ;
await conn . query ( q , params ) ;
}
}
await conn . commit ( ) ;
cache . ticketIndex = idx ;
} catch ( e ) {
await conn . rollback ( ) ;
console . error ( 'saveTicketIndex error' , e ) ;
throw e ;
} finally {
conn . release ( ) ;
}
} ,
upsertTicketIndex : async ( update ) => {
const id = String ( update . ticket _id || '' ) . trim ( ) ;
if ( ! id ) return ;
const cur = cache . ticketIndex [ id ] || { } ;
const merged = { ... cur , ... update , last _update _ts : Date . now ( ) } ;
cache . ticketIndex [ id ] = merged ;
// Efficient single update
try {
await pool . query ( 'INSERT INTO ticket_index (ticket_id, data, last_update_ts) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE data = ?, last_update_ts = ?' ,
[ id , JSON . stringify ( merged ) , merged . last _update _ts , JSON . stringify ( merged ) , merged . last _update _ts ] ) ;
} catch ( e ) { console . error ( 'upsertTicketIndex error' , e ) ; }
return merged ;
} ,
// Stats
appendStatTicket : async ( item ) => {
cache . statsTicket . push ( item ) ;
try {
await pool . query ( 'INSERT INTO stats_ticket (data) VALUES (?)' , [ JSON . stringify ( item ) ] ) ;
} catch ( e ) { console . error ( 'appendStatTicket error' , e ) ; }
} ,
getStatsTicket : ( ) => cache . statsTicket ,
appendStatGate : async ( item ) => {
cache . statsGate . push ( item ) ;
try {
await pool . query ( 'INSERT INTO stats_gate (data) VALUES (?)' , [ JSON . stringify ( item ) ] ) ;
} catch ( e ) { console . error ( 'appendStatGate error' , e ) ; }
} ,
getStatsGate : ( ) => cache . statsGate ,
// Export
buildExportPayload : ( ) => ( {
config : DataService . getConfig ( ) ,
stations : DataService . getStations ( ) ,
lines : DataService . getLines ( ) ,
fares : DataService . getFares ( ) ,
stats _ticket : DataService . getStatsTicket ( ) ,
stats _gate : DataService . getStatsGate ( ) ,
} ) ,
saveExport : ( ) => {
try { fs . writeFileSync ( paths . export , JSON . stringify ( DataService . buildExportPayload ( ) , null , 2 ) ) ; } catch ( e ) { }
}
} ;
module . exports = DataService ;