- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
11 KiB
Lua
296 lines
11 KiB
Lua
-- =====================================================================
|
|
-- Aether Bank — Serverlogik
|
|
-- Alle Geldbewegungen und die PIN-Pruefung laufen ausschliesslich hier.
|
|
-- Die PIN verlaesst den Server niemals.
|
|
-- =====================================================================
|
|
|
|
local Core = exports['aether-core']
|
|
|
|
-- Wie viele Buchungen an die Oberflaeche geschickt werden
|
|
local MAX_BUCHUNGEN = 60
|
|
-- Hoechstbetrag je Einzelvorgang (Schutz vor absurden Eingaben)
|
|
local MAX_BETRAG = 10000000
|
|
|
|
-- Merkt sich, wer sich an einem Automaten bereits mit PIN angemeldet hat
|
|
local angemeldet = {}
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Hilfsfunktionen
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Liefert die Charakter-ID eines Spielers
|
|
--- @param quelle number Server-ID
|
|
--- @return number|nil
|
|
local function CharakterVon(quelle)
|
|
return Core:HoleCharakterId(quelle)
|
|
end
|
|
|
|
--- Prueft, ob ein Betrag eine gueltige, positive Ganzzahl ist
|
|
--- @param betrag any Eingabe
|
|
--- @return number|nil Bereinigter Betrag oder nil
|
|
local function PruefeBetrag(betrag)
|
|
if type(betrag) ~= 'number' then return nil end
|
|
betrag = math.floor(betrag)
|
|
if betrag < 1 or betrag > MAX_BETRAG then return nil end
|
|
return betrag
|
|
end
|
|
|
|
--- Ordnet einer Buchungsart Symbol und Kategorie zu (nur fuer die Anzeige)
|
|
--- @param art string Buchungsart aus der Datenbank
|
|
--- @return string Symbol, string Kategorie
|
|
local function Darstellung(art)
|
|
local tabelle = {
|
|
deposit = { '💵', 'Einzahlung' },
|
|
withdraw = { '🏧', 'Auszahlung' },
|
|
transfer = { '↗', 'Transfer' },
|
|
salary = { '💼', 'Einkommen' },
|
|
fine = { '⚖️', 'Strafe' },
|
|
}
|
|
local eintrag = tabelle[art] or { '•', 'Sonstiges' }
|
|
return eintrag[1], eintrag[2]
|
|
end
|
|
|
|
--- Laedt die letzten Buchungen eines Charakters fuer die Anzeige
|
|
--- @param charakterId number Charakter-ID
|
|
--- @return table Liste von Buchungen
|
|
local function HoleBuchungen(charakterId)
|
|
local zeilen = MySQL.query.await([[
|
|
SELECT type, amount, balance, label, created_at
|
|
FROM aether_transactions
|
|
WHERE `character` = ?
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
]], { charakterId, MAX_BUCHUNGEN }) or {}
|
|
|
|
local liste = {}
|
|
for _, zeile in ipairs(zeilen) do
|
|
local icon, kategorie = Darstellung(zeile.type)
|
|
liste[#liste + 1] = {
|
|
titel = zeile.label ~= '' and zeile.label or kategorie,
|
|
betrag = zeile.amount,
|
|
saldo = zeile.balance,
|
|
zeit = tostring(zeile.created_at):sub(1, 16),
|
|
kat = kategorie,
|
|
icon = icon,
|
|
}
|
|
end
|
|
return liste
|
|
end
|
|
|
|
--- Baut den Datensatz, den die Oberflaeche anzeigt (ohne PIN!)
|
|
--- @param quelle number Server-ID
|
|
--- @return table|nil
|
|
local function BaueKonto(quelle)
|
|
local charakterId = CharakterVon(quelle)
|
|
if not charakterId then return nil end
|
|
|
|
local daten = Core:HoleSpielerInfo(quelle)
|
|
if not daten then return nil end
|
|
|
|
return {
|
|
inhaber = (daten.vorname or '') .. ' ' .. (daten.nachname or ''),
|
|
iban = daten.iban or '—',
|
|
saldo = daten.konto or 0,
|
|
bargeld = daten.bargeld or 0,
|
|
transaktionen = HoleBuchungen(charakterId),
|
|
}
|
|
end
|
|
|
|
--- Schickt den aktuellen Kontostand an die Oberflaeche
|
|
--- @param quelle number Server-ID
|
|
--- @param hinweis string|nil Rueckmeldung
|
|
local function SendeKonto(quelle, hinweis)
|
|
local konto = BaueKonto(quelle)
|
|
if not konto then return end
|
|
konto.hinweis = hinweis
|
|
TriggerClientEvent('aether:bank:konto', quelle, konto)
|
|
end
|
|
|
|
--- Lehnt eine Aktion mit Begruendung ab
|
|
local function Ablehnen(quelle, grund)
|
|
TriggerClientEvent('aether:bank:fehler', quelle, grund)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Automat oeffnen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:oeffnen', function()
|
|
local quelle = source
|
|
if not CharakterVon(quelle) then return end
|
|
|
|
angemeldet[quelle] = false -- jede Sitzung beginnt mit PIN-Eingabe
|
|
TriggerClientEvent('aether:bank:zeige', quelle)
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- PIN pruefen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:pinPruefen', function(pin)
|
|
local quelle = source
|
|
local charakterId = CharakterVon(quelle)
|
|
if not charakterId or type(pin) ~= 'string' then return end
|
|
|
|
local gespeichert = MySQL.scalar.await(
|
|
'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId })
|
|
|
|
if gespeichert ~= pin then
|
|
angemeldet[quelle] = false
|
|
TriggerClientEvent('aether:bank:pinErgebnis', quelle, false)
|
|
return
|
|
end
|
|
|
|
angemeldet[quelle] = true
|
|
TriggerClientEvent('aether:bank:pinErgebnis', quelle, true, BaueKonto(quelle))
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- PIN aendern
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:pinAendern', function(alt, neu)
|
|
local quelle = source
|
|
local charakterId = CharakterVon(quelle)
|
|
if not charakterId then return end
|
|
if type(alt) ~= 'string' or type(neu) ~= 'string' then return end
|
|
|
|
-- Neue PIN muss genau vier Ziffern haben
|
|
if not neu:match('^%d%d%d%d$') then
|
|
Ablehnen(quelle, 'Die neue PIN muss aus vier Ziffern bestehen.')
|
|
return
|
|
end
|
|
|
|
local gespeichert = MySQL.scalar.await(
|
|
'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId })
|
|
|
|
if gespeichert ~= alt then
|
|
Ablehnen(quelle, 'Die aktuelle PIN ist falsch.')
|
|
return
|
|
end
|
|
|
|
MySQL.update.await('UPDATE aether_characters SET bank_pin = ? WHERE id = ?', { neu, charakterId })
|
|
TriggerClientEvent('aether:bank:erfolg', quelle, 'PIN geändert', 'Deine neue PIN ist ab sofort gültig.')
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Auszahlen (Konto -> Bargeld)
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:auszahlen', function(betrag)
|
|
local quelle = source
|
|
if not angemeldet[quelle] then return end
|
|
|
|
betrag = PruefeBetrag(betrag)
|
|
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
|
|
|
|
-- Erst abbuchen (prueft die Deckung), dann als Bargeld gutschreiben
|
|
if not Core:EntferneGeld(quelle, 'konto', betrag, 'Barauszahlung Automat') then
|
|
Ablehnen(quelle, 'Nicht genügend Guthaben.')
|
|
return
|
|
end
|
|
Core:FuegeGeldHinzu(quelle, 'bargeld', betrag, 'Barauszahlung')
|
|
|
|
SendeKonto(quelle)
|
|
TriggerClientEvent('aether:bank:erfolg', quelle,
|
|
'Bitte entnehmen Sie Ihr Geld', betrag .. ' € wurden ausgezahlt.')
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Einzahlen (Bargeld -> Konto)
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:einzahlen', function(betrag)
|
|
local quelle = source
|
|
if not angemeldet[quelle] then return end
|
|
|
|
betrag = PruefeBetrag(betrag)
|
|
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
|
|
|
|
if not Core:EntferneGeld(quelle, 'bargeld', betrag, 'Bareinzahlung') then
|
|
Ablehnen(quelle, 'Du hast nicht so viel Bargeld dabei.')
|
|
return
|
|
end
|
|
Core:FuegeGeldHinzu(quelle, 'konto', betrag, 'Bareinzahlung Automat')
|
|
|
|
SendeKonto(quelle)
|
|
TriggerClientEvent('aether:bank:erfolg', quelle,
|
|
'Einzahlung erfolgreich', betrag .. ' € wurden gutgeschrieben.')
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Ueberweisen (Konto -> fremdes Konto, auch offline)
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:ueberweisen', function(ziel, betrag, zweck)
|
|
local quelle = source
|
|
if not angemeldet[quelle] then return end
|
|
|
|
local charakterId = CharakterVon(quelle)
|
|
if not charakterId then return end
|
|
|
|
betrag = PruefeBetrag(betrag)
|
|
if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end
|
|
if type(ziel) ~= 'string' or #ziel < 3 then
|
|
Ablehnen(quelle, 'Bitte eine gültige IBAN angeben.')
|
|
return
|
|
end
|
|
|
|
zweck = type(zweck) == 'string' and zweck:sub(1, 64) or ''
|
|
|
|
-- Empfaenger ueber die IBAN suchen
|
|
local empfaenger = MySQL.query.await([[
|
|
SELECT id, firstname, lastname, bank
|
|
FROM aether_characters
|
|
WHERE iban = ? AND deleted = 0
|
|
LIMIT 1
|
|
]], { ziel })
|
|
|
|
if not empfaenger or not empfaenger[1] then
|
|
Ablehnen(quelle, 'Kein Konto mit dieser IBAN gefunden.')
|
|
return
|
|
end
|
|
|
|
local ziel_ = empfaenger[1]
|
|
if ziel_.id == charakterId then
|
|
Ablehnen(quelle, 'Du kannst dir nicht selbst überweisen.')
|
|
return
|
|
end
|
|
|
|
local empfaengerName = ziel_.firstname .. ' ' .. ziel_.lastname
|
|
local verwendung = zweck ~= '' and (' (' .. zweck .. ')') or ''
|
|
|
|
-- Abbuchen (prueft die Deckung)
|
|
if not Core:EntferneGeld(quelle, 'konto', betrag,
|
|
'Überweisung an ' .. empfaengerName .. verwendung) then
|
|
Ablehnen(quelle, 'Nicht genügend Guthaben.')
|
|
return
|
|
end
|
|
|
|
-- Gutschrift: bei Online-Spielern ueber den Core, sonst direkt in der Datenbank
|
|
local zielQuelle = Core:HoleQuelleNachCharakter(ziel_.id)
|
|
if zielQuelle then
|
|
Core:FuegeGeldHinzu(zielQuelle, 'konto', betrag,
|
|
'Überweisung von Konto' .. verwendung)
|
|
TriggerClientEvent('aether:bank:benachrichtigung', zielQuelle,
|
|
('Du hast %d € erhalten.'):format(betrag))
|
|
else
|
|
local neuerStand = (ziel_.bank or 0) + betrag
|
|
MySQL.update.await('UPDATE aether_characters SET bank = ? WHERE id = ?',
|
|
{ neuerStand, ziel_.id })
|
|
MySQL.insert('INSERT INTO aether_transactions (`character`, type, amount, balance, label) VALUES (?, ?, ?, ?, ?)',
|
|
{ ziel_.id, 'transfer', betrag, neuerStand, 'Überweisung erhalten' .. verwendung })
|
|
end
|
|
|
|
Aether.Info(('Überweisung: Charakter #%d -> #%d, %d €'):format(charakterId, ziel_.id, betrag))
|
|
|
|
SendeKonto(quelle)
|
|
TriggerClientEvent('aether:bank:erfolg', quelle,
|
|
'Überweisung ausgeführt', ('%d € an %s gesendet.'):format(betrag, empfaengerName))
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Aufraeumen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:bank:schliessen', function()
|
|
angemeldet[source] = nil
|
|
end)
|
|
|
|
AddEventHandler('playerDropped', function()
|
|
angemeldet[source] = nil
|
|
end)
|