mining checker
This commit is contained in:
@@ -56,6 +56,18 @@ return [
|
||||
'supports_tray' => false,
|
||||
'module_name' => 'desktop',
|
||||
'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.',
|
||||
'native_module' => [
|
||||
'initializer' => 'initUserSelfManagementApp',
|
||||
'options' => [],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/assets/apps/user-self-management/app.css'],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => '/assets/apps/user-self-management/app.js'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'app_id' => 'admin-apps',
|
||||
@@ -98,30 +110,4 @@ return [
|
||||
'module_name' => 'auth',
|
||||
'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.',
|
||||
],
|
||||
[
|
||||
'app_id' => 'mining-checker',
|
||||
'title' => 'Mining-Checker',
|
||||
'icon' => 'MC',
|
||||
'entry_route' => '/apps/mining-checker',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1180,
|
||||
'default_height' => 760,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => true,
|
||||
'module_name' => 'finance',
|
||||
'summary' => 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.',
|
||||
'module_options' => [
|
||||
'default_project_key' => 'doge-main',
|
||||
'active_view' => 'overview',
|
||||
'sections' => [
|
||||
['key' => 'overview', 'label' => 'Übersicht'],
|
||||
['key' => 'upload', 'label' => 'Upload'],
|
||||
['key' => 'measurements', 'label' => 'Mining-History'],
|
||||
['key' => 'wallet', 'label' => 'Wallet'],
|
||||
['key' => 'mining', 'label' => 'Miner-Daten'],
|
||||
['key' => 'dashboards', 'label' => 'Dashboards'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -36,6 +36,7 @@ Das Startmenue ist in drei Bereiche gegliedert:
|
||||
- Desktop-Icons koennen direkt geoeffnet werden.
|
||||
- Im Startmenue lassen sich Programme ueber den `Auswahlbereich` starten.
|
||||
- Fenster koennen verschoben, minimiert, maximiert und geschlossen werden.
|
||||
- Programme koennen sowohl globale System-Apps als auch klassische Module sein.
|
||||
|
||||
## Einstellungen oeffnen
|
||||
|
||||
@@ -48,6 +49,16 @@ Dort koennen derzeit insbesondere verwaltet werden:
|
||||
- sichtbare Apps
|
||||
- Inhalte des `Infobereichs`
|
||||
|
||||
## Module
|
||||
|
||||
Klassische Module liegen unter `modules/<modul>/` und koennen als normale `App` im Desktop erscheinen.
|
||||
|
||||
Aktuell gilt:
|
||||
|
||||
- der `Mining-Checker` ist das erste echte Modul in dieser Form
|
||||
- Modul-Businesslogik bleibt im Modul und wird nicht in den Desktop-Core verschoben
|
||||
- gemeinsame Desktop-Mechaniken wie Fenster, Asset-Einbindung und Zugriffsschutz werden global bereitgestellt
|
||||
|
||||
## Benutzerdaten
|
||||
|
||||
Aktuell gelten folgende Regeln:
|
||||
|
||||
@@ -80,6 +80,8 @@ Stand dieser Datei:
|
||||
- Startmenue mit `User Setting Bereich`, `Funktion-Bereich` und `Auswahlbereich`
|
||||
- rechter `Infobereich` mit benutzerbezogener Sichtbarkeit
|
||||
- `User Self Management` als Setup-App
|
||||
- erster echter Modulpfad fuer klassische Module mit Modul-Discovery aus `modules/<modul>/`
|
||||
- `Mining-Checker` als erstes angebundenes klassisches Modul
|
||||
- Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix
|
||||
- vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende
|
||||
- LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort
|
||||
@@ -105,6 +107,14 @@ Aus [modules/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/m
|
||||
|
||||
- klassische Module bleiben unter `modules/<modul>/`
|
||||
- Businesslogik wird nicht in den Desktop-Core verschoben
|
||||
- ein Desktop-Modul kann eigene `desktop.php`, `pages/`, `api/` und `assets/` Strukturen mitbringen
|
||||
- Modul-Assets muessen nicht in den globalen Desktop-Asset-Baum kopiert werden
|
||||
|
||||
### Aktueller Modulstand
|
||||
|
||||
- `Mining-Checker` ist das erste Modul, das als echte Desktop-App aus `modules/mining-checker/` angebunden wird
|
||||
- die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen
|
||||
- wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul
|
||||
|
||||
### Skins
|
||||
|
||||
@@ -125,6 +135,7 @@ Fuer einen spaeteren Hilfebereich sollen Inhalte aus dieser Datei in Themenblcke
|
||||
- `Fenster verwenden`
|
||||
- `Infobereich konfigurieren`
|
||||
- `Apps und Desktop Type verwalten`
|
||||
- `Module verstehen und starten`
|
||||
- `Benutzerdaten und Synchronisierung`
|
||||
|
||||
Die Inhalte sollen spaeter moeglichst nicht neu erfunden, sondern aus den zentral gepflegten Dateien abgeleitet werden.
|
||||
|
||||
@@ -23,6 +23,7 @@ Zentraler Dokumentationsindex fuer das Projekt.
|
||||
|
||||
- `public/` Web-Root mit Desktop-Shell auf `/`
|
||||
- `src/Desktop/` zentrale Desktop-Mechaniken
|
||||
- `src/ModulesCore/` gemeinsame Helfer fuer Modul-Discovery, Modul-HTTP und wiederverwendbare Modul-Anbindung
|
||||
- `partials/desktop/` Shell-Template
|
||||
- `modules/` Zielort fuer klassische Module
|
||||
- `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module
|
||||
@@ -43,3 +44,8 @@ Es gilt:
|
||||
- wichtige Inhalte aus Unterordner-`README.md`-Dateien werden zentral in `docs/CONTENT.md` gespiegelt
|
||||
- Nutzungswissen wird in `docs/ANLEITUNG.md` gepflegt
|
||||
- Entwicklungsregeln werden in `docs/WEITERENTWICKLUNG.md` gepflegt
|
||||
|
||||
Aktuell wichtig:
|
||||
|
||||
- der Mining-Checker ist das erste als echtes Desktop-Modul angebundene Modul unter `modules/mining-checker/`
|
||||
- Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Umsetzungsstatus Desktop UI
|
||||
|
||||
Stand: 2026-06-07
|
||||
Stand: 2026-06-21
|
||||
|
||||
Diese Datei ist die laufende Arbeitsgrundlage fuer die Umsetzung der Anweisungen aus `docs/Umsetzungsanweisung/`.
|
||||
|
||||
@@ -23,8 +23,10 @@ Aktuell ist das Projekt auf einem V1-Scaffold-Stand:
|
||||
- Skin-System `Windows` / `Apple` / `Linux` ist vorbereitet
|
||||
- einfacher Fenster-Manager ist vorhanden
|
||||
- App-Registry, Widget-Registry und Import-Basis sind angelegt
|
||||
- erster echter Modulmechanismus fuer klassische Module ist vorhanden
|
||||
- `Mining-Checker` ist als erstes klassisches Modul angebunden
|
||||
- Keycloak ist nur konzeptionell vorbereitet, nicht integriert
|
||||
- Admin-Bereiche, persistente User-Desktops und echte Modul-Anbindung sind noch offen
|
||||
- Admin-Bereiche und persistente User-Desktops sind noch offen
|
||||
|
||||
## Verbindliche Regeln
|
||||
|
||||
@@ -49,7 +51,7 @@ Erfuellt:
|
||||
Offen:
|
||||
|
||||
- globale Verwaltungsseiten nur als Platzhalter vorhanden
|
||||
- klassische Module sind noch nicht schrittweise an die Shell angebunden
|
||||
- weitere klassische Module sind noch nicht schrittweise an die Shell angebunden
|
||||
- persoenliche Dashboards sind noch nicht funktional
|
||||
|
||||
Nachweise:
|
||||
@@ -98,7 +100,7 @@ Erfuellt:
|
||||
|
||||
Offen:
|
||||
|
||||
- `modules/<modul>/` im neuen Projekt enthalten noch keine wirklich angebundenen Module
|
||||
- weitere `modules/<modul>/` sind noch nicht angebunden
|
||||
- `partials/landingpages/` und `partials/structure/` sind nur minimal vorbereitet
|
||||
- `tools/` und `debug/` sind noch nicht mit Funktion befuellt
|
||||
- Skin-Ordnerkonzept mit `base`-Fallback sowie pro Skin getrennten Layout-/Asset-Dateien fachlich fertigziehen und abnehmen
|
||||
@@ -119,18 +121,24 @@ Erfuellt:
|
||||
- Rohkopie der Altmodule liegt in `temp/nexus-module-import/modules/`
|
||||
- relevante Doku aus der Altbasis wurde in den Import-Ordner uebernommen
|
||||
- Produktivcode referenziert den Import-Ordner nicht zur Laufzeit
|
||||
- `Mining-Checker` wurde als erstes Modul aus der Projektstruktur `modules/mining-checker/` an die Shell angebunden
|
||||
- Modul-Assets werden aus dem Modul selbst geladen statt ueber eine alte Sonderkopie
|
||||
- gemeinsame Modul-Helfer fuer Discovery und Zugriffsschutz sind im globalen Kern vorbereitet
|
||||
|
||||
Offen:
|
||||
|
||||
- keine Modulpruefung je Modul durchgefuehrt
|
||||
- keine App-Registry-Eintraege fuer echte importierte Module vorhanden
|
||||
- keine Modulansichten in die Fensterlogik integriert
|
||||
- weitere App-Registry-Eintraege fuer echte importierte Module fehlen noch
|
||||
- Fenstertauglichkeit weiterer Module ist nicht geprueft
|
||||
- kein Skin-Test je Modul erfolgt
|
||||
|
||||
Nachweise:
|
||||
|
||||
- [temp/nexus-module-import](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/temp/nexus-module-import:1)
|
||||
- [config/apps.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/config/apps.php:1)
|
||||
- [modules/mining-checker](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/modules/mining-checker:1)
|
||||
- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1)
|
||||
- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1)
|
||||
|
||||
### 5. `04_UMSETZUNGSPLAN_V1.md`
|
||||
|
||||
@@ -168,7 +176,7 @@ Phase 4 Inhaltssystem:
|
||||
|
||||
- `ERLEDIGT`: App Registry als Grundstruktur
|
||||
- `TEILWEISE`: Widget Registry als Grundstruktur, noch nicht abgenommen
|
||||
- `OFFEN`: Seitenmodule
|
||||
- `TEILWEISE`: Seitenmodule, erster echter Modulpfad vorhanden
|
||||
- `OFFEN`: persoenliche Linklisten
|
||||
- `TEILWEISE`: oeffentliches Home-Dashboard nur als Placeholder
|
||||
- `TEILWEISE`: persoenliche Workspaces nur als Placeholder
|
||||
@@ -185,8 +193,8 @@ Phase 5 Admin-Bereiche:
|
||||
Phase 6 Modul-Anbindung:
|
||||
|
||||
- `TEILWEISE`: importierte Nexus-Module liegen vor
|
||||
- `OFFEN`: erste Module als Apps in der Shell
|
||||
- `OFFEN`: Fenstertauglichkeit je Modul pruefen
|
||||
- `TEILWEISE`: erstes Modul als App in der Shell
|
||||
- `OFFEN`: Fenstertauglichkeit weiterer Module pruefen
|
||||
- `OFFEN`: langfristige UX-Anpassung je Modul
|
||||
|
||||
V1-Minimalziel:
|
||||
@@ -244,17 +252,23 @@ Vorhanden:
|
||||
- JSON-Payload fuer Frontend per `api/desktop.php`
|
||||
- PHP-Autoload-Basis
|
||||
- App-Registry
|
||||
- Modul-Registry fuer klassische Module
|
||||
- Widget-Registry
|
||||
- Skin-Resolver
|
||||
- Default-Workspace- und Widget-State
|
||||
- einfacher Window-Manager
|
||||
- Window-Controls fuer minimieren, maximieren und schliessen
|
||||
- CSS/JS fuer Shell, Fenster, Taskbar, Startmenue, Widgets und Uhr
|
||||
- gemeinsamer Modul-Asset-Endpoint
|
||||
- `Mining-Checker` als erstes echtes Modul unter `modules/`
|
||||
|
||||
Hauptdateien:
|
||||
|
||||
- [public/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/index.php:1)
|
||||
- [api/desktop.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/api/desktop.php:1)
|
||||
- [src/App/App.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/App/App.php:1)
|
||||
- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1)
|
||||
- [src/ModulesCore/ModuleHttp.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleHttp.php:1)
|
||||
- [src/Desktop/DesktopState.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/Desktop/DesktopState.php:1)
|
||||
- [public/assets/desktop/desktop.js](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/assets/desktop/desktop.js:1)
|
||||
- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1)
|
||||
|
||||
@@ -48,6 +48,7 @@ Verbindlich ist:
|
||||
|
||||
- `modules/<modul>/` bleibt Ort fuer klassische Module
|
||||
- globale Desktop-Mechaniken liegen im gemeinsamen Kern
|
||||
- globale Modul-Helfer duerfen im gemeinsamen Kern liegen, Fachlogik aber nicht
|
||||
- Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik
|
||||
- Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ Klassische Module bleiben in diesem Projekt unter `modules/<modul>/`.
|
||||
|
||||
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
|
||||
|
||||
## Aktuelle Modulstruktur
|
||||
|
||||
Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen:
|
||||
|
||||
- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen
|
||||
- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte
|
||||
- `api/` fuer modulinterne HTTP-Endpunkte
|
||||
- `assets/` fuer modulnahe CSS- und JS-Dateien
|
||||
|
||||
Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert.
|
||||
|
||||
## Pflegehinweis
|
||||
|
||||
Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern.
|
||||
|
||||
17
modules/mining-checker/api/index.php
Normal file
17
modules/mining-checker/api/index.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/mining-checker/index.php/';
|
||||
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
$relativePath = str_starts_with($requestPath, $prefix)
|
||||
? substr($requestPath, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
}
|
||||
|
||||
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($relativePath);
|
||||
0
modules/mining-checker/assets/css/.gitkeep
Normal file
0
modules/mining-checker/assets/css/.gitkeep
Normal file
0
modules/mining-checker/assets/js/.gitkeep
Normal file
0
modules/mining-checker/assets/js/.gitkeep
Normal file
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
function boot(root) {
|
||||
const root = document.getElementById('mining-checker-app');
|
||||
if (!root || !window.React || !window.ReactDOM) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
const h = React.createElement;
|
||||
@@ -26,8 +26,7 @@
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const initialDebugMode = root.dataset.moduleDebugEnabled === '1'
|
||||
|| (document.body && document.body.dataset.moduleDebugEnabled === '1');
|
||||
const initialDebugMode = document.body && document.body.dataset.moduleDebugEnabled === '1';
|
||||
function getCookie(name) {
|
||||
const pattern = `; ${document.cookie}`;
|
||||
const parts = pattern.split(`; ${name}=`);
|
||||
@@ -260,8 +259,8 @@
|
||||
const requestOptions = options && typeof options === 'object' ? { ...options } : {};
|
||||
const debugEnabled = !!debugBus.enabled;
|
||||
const timeoutMs = typeof requestOptions.timeoutMs === 'number'
|
||||
? (debugEnabled ? Math.max(requestOptions.timeoutMs, 20000) : requestOptions.timeoutMs)
|
||||
: (debugEnabled ? 20000 : 8000);
|
||||
? (debugEnabled ? Math.max(requestOptions.timeoutMs, 30000) : requestOptions.timeoutMs)
|
||||
: (debugEnabled ? 30000 : 20000);
|
||||
delete requestOptions.timeoutMs;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -1204,7 +1203,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBootstrap(key) {
|
||||
async function loadBootstrap(key, options) {
|
||||
const suppressError = !!(options && options.suppressError);
|
||||
const cacheKey = `${key}:${activeTab || 'overview'}`;
|
||||
const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null;
|
||||
|
||||
@@ -1220,18 +1220,20 @@
|
||||
loadGuardTriggered = true;
|
||||
setLoading(false);
|
||||
setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key));
|
||||
if (!suppressError) {
|
||||
setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.');
|
||||
}, 12000);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) {
|
||||
setPayload(normalizeBootstrap(null, key));
|
||||
setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ view: activeTab || 'overview' });
|
||||
const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 10000 });
|
||||
const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 25000 });
|
||||
const normalized = normalizeBootstrap(data, key);
|
||||
bootstrapCacheRef.current.set(cacheKey, normalized);
|
||||
setPayload(normalized);
|
||||
@@ -1252,9 +1254,13 @@
|
||||
...previous,
|
||||
currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR',
|
||||
}));
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!suppressError) {
|
||||
setError(err.message);
|
||||
}
|
||||
setPayload(normalizeBootstrap(null, key));
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(loadGuard);
|
||||
if (!loadGuardTriggered) {
|
||||
@@ -1263,6 +1269,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadBootstrapAfterMutation(successMessage) {
|
||||
const refreshed = await loadBootstrap(projectKey, { suppressError: true });
|
||||
if (!refreshed) {
|
||||
setError('Speichern war erfolgreich, aber die Ansicht konnte nicht automatisch aktualisiert werden.');
|
||||
if (successMessage) {
|
||||
setMessage(successMessage);
|
||||
}
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
function invalidateProjectBootstrapCache(key) {
|
||||
const prefix = `${key}:`;
|
||||
Array.from(bootstrapCacheRef.current.keys()).forEach((cacheKey) => {
|
||||
if (String(cacheKey).startsWith(prefix)) {
|
||||
bootstrapCacheRef.current.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySavedPayout(savedPayout) {
|
||||
if (!savedPayout || typeof savedPayout !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPayload((previous) => {
|
||||
const current = previous || normalizeBootstrap(null, projectKey);
|
||||
const previousPayouts = Array.isArray(current.settings?.payouts) ? current.settings.payouts : [];
|
||||
const savedId = String(savedPayout.id ?? '');
|
||||
const payouts = previousPayouts
|
||||
.filter((row) => savedId === '' || String(row?.id ?? '') !== savedId)
|
||||
.concat(savedPayout)
|
||||
.sort((left, right) => String(left?.payout_at || '').localeCompare(String(right?.payout_at || '')));
|
||||
const totalCoins = payouts.reduce((sum, row) => {
|
||||
const amount = Number(row?.coins_amount);
|
||||
return Number.isFinite(amount) ? sum + amount : sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
...current,
|
||||
settings: {
|
||||
...(current.settings || {}),
|
||||
payouts,
|
||||
},
|
||||
summary: {
|
||||
...(current.summary || {}),
|
||||
payouts: {
|
||||
...(current.summary?.payouts || {}),
|
||||
total_count: payouts.length,
|
||||
total_coins: totalCoins,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBootstrap(projectKey);
|
||||
}, [projectKey, activeTab]);
|
||||
@@ -1416,7 +1478,7 @@
|
||||
source: 'manual',
|
||||
});
|
||||
setOcrPreview(null);
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1445,7 +1507,7 @@
|
||||
});
|
||||
setMessage('Wallet-Snapshot gespeichert.');
|
||||
setOcrPreview(null);
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Wallet-Snapshot gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1470,7 +1532,7 @@
|
||||
method: 'DELETE',
|
||||
});
|
||||
setMessage('Messpunkt geloescht.');
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Messpunkt geloescht.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1501,7 +1563,7 @@
|
||||
if (!result.error_count) {
|
||||
setImportForm((previous) => ({ ...previous, rows_text: '' }));
|
||||
}
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation(`Import abgeschlossen: ${summary}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1562,7 +1624,7 @@
|
||||
}),
|
||||
});
|
||||
setMessage('Dashboard gespeichert.');
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Dashboard gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1591,7 +1653,7 @@
|
||||
}),
|
||||
});
|
||||
setMessage('Settings gespeichert.');
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Settings gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1648,7 +1710,7 @@
|
||||
setMessage('Ziel gespeichert.');
|
||||
setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 });
|
||||
setTargetModalOpen(false);
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Ziel gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1669,7 +1731,7 @@
|
||||
method: 'DELETE',
|
||||
});
|
||||
setMessage('Ziel geloescht.');
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Ziel geloescht.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1691,7 +1753,7 @@
|
||||
body: JSON.stringify({ auto_renew: !row.auto_renew }),
|
||||
});
|
||||
setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`);
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation(`Automatische Verlaengerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1726,7 +1788,7 @@
|
||||
is_active: true,
|
||||
});
|
||||
setCostPlanModalOpen(false);
|
||||
await loadBootstrap(projectKey);
|
||||
await reloadBootstrapAfterMutation('Miner gespeichert.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -1738,16 +1800,19 @@
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, {
|
||||
const savedPayout = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payoutForm),
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
applySavedPayout(savedPayout);
|
||||
invalidateProjectBootstrapCache(projectKey);
|
||||
setMessage('Auszahlung gespeichert.');
|
||||
setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' });
|
||||
setPayoutModalOpen(false);
|
||||
await loadBootstrap(projectKey);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -2203,6 +2268,16 @@
|
||||
value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a',
|
||||
sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''),
|
||||
}),
|
||||
h(StatCard, {
|
||||
key: 'perday-since-payout',
|
||||
label: `${currentCoinCurrency} pro Tag seit letzter Auszahlung`,
|
||||
value: latest && latest.doge_per_day_since_last_payout !== null && latest.doge_per_day_since_last_payout !== undefined
|
||||
? fmtNumber(latest.doge_per_day_since_last_payout, 4)
|
||||
: 'n/a',
|
||||
sub: latest && latest.last_payout_at
|
||||
? `Seit ${fmtDate(latest.last_payout_at)} · ${fmtNumber(latest.coins_since_last_payout, 6)} ${currentCoinCurrency}`
|
||||
: 'Noch keine Auszahlung vor dem letzten Upload',
|
||||
}),
|
||||
h(StatCard, {
|
||||
key: 'value',
|
||||
label: 'Aktueller Gegenwert',
|
||||
@@ -2273,7 +2348,7 @@
|
||||
panel('Mining-History', 'Die letzten 10 Mining-Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [
|
||||
h('table', { key: 'table', className: 'mc-table' }, [
|
||||
h('thead', { key: 'thead' }, h('tr', null, [
|
||||
'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion'
|
||||
'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Seit Auszahlung/Tag', 'Trend', 'Notiz', 'Aktion'
|
||||
].map((label) => h('th', { key: label }, label)))),
|
||||
h('tbody', { key: 'tbody' },
|
||||
measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [
|
||||
@@ -2282,6 +2357,9 @@
|
||||
h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'),
|
||||
h('td', { key: 'source' }, row.source),
|
||||
h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)),
|
||||
h('td', { key: 'rate-payout' }, row.doge_per_day_since_last_payout !== null && row.doge_per_day_since_last_payout !== undefined
|
||||
? `${fmtNumber(row.doge_per_day_since_last_payout, 4)}${row.last_payout_at ? ` seit ${fmtDate(row.last_payout_at)}` : ''}`
|
||||
: 'n/a'),
|
||||
h('td', { key: 'trend' }, row.trend_label),
|
||||
h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'),
|
||||
h('td', { key: 'action' }, h('button', {
|
||||
@@ -3186,46 +3264,4 @@
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(h(App));
|
||||
return true;
|
||||
}
|
||||
|
||||
window.initMiningCheckerApp = function initMiningCheckerApp(root, options) {
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = options && typeof options === 'object' ? options : {};
|
||||
|
||||
if (config.apiBase) {
|
||||
root.dataset.apiBase = String(config.apiBase);
|
||||
}
|
||||
if (config.defaultProjectKey) {
|
||||
root.dataset.defaultProjectKey = String(config.defaultProjectKey);
|
||||
}
|
||||
if (config.activeView) {
|
||||
root.dataset.activeView = String(config.activeView);
|
||||
}
|
||||
if (config.sections) {
|
||||
root.dataset.sectionsJson = JSON.stringify(config.sections);
|
||||
}
|
||||
if (config.moduleDebugEnabled !== undefined) {
|
||||
root.dataset.moduleDebugEnabled = config.moduleDebugEnabled ? '1' : '0';
|
||||
}
|
||||
|
||||
if (root.dataset.miningCheckerMounted === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mounted = boot(root);
|
||||
if (mounted) {
|
||||
root.dataset.miningCheckerMounted = '1';
|
||||
}
|
||||
|
||||
return mounted;
|
||||
};
|
||||
|
||||
const root = document.getElementById('mining-checker-app');
|
||||
if (root) {
|
||||
window.initMiningCheckerApp(root);
|
||||
}
|
||||
})();
|
||||
17
modules/mining-checker/bootstrap.php
Normal file
17
modules/mining-checker/bootstrap.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\MiningChecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
24
modules/mining-checker/config/example.config.php
Normal file
24
modules/mining-checker/config/example.config.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
|
||||
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
|
||||
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
|
||||
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
|
||||
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
|
||||
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
|
||||
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
|
||||
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
|
||||
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
|
||||
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
|
||||
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
|
||||
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'MINING_CHECKER_FX_TIMEOUT' => '10',
|
||||
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
|
||||
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
|
||||
];
|
||||
46
modules/mining-checker/config/module.php
Normal file
46
modules/mining-checker/config/module.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'use_project_database' => true,
|
||||
'table_prefix' => 'miningcheck_',
|
||||
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
|
||||
'uploads_public_prefix' => '/data/mining-checker/uploads',
|
||||
'ocr' => [
|
||||
'providers' => array_values(array_filter(array_map(
|
||||
static fn (string $provider): string => trim(strtolower($provider)),
|
||||
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
|
||||
))),
|
||||
'ocrspace' => [
|
||||
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
|
||||
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
|
||||
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
|
||||
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
|
||||
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
|
||||
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
|
||||
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
|
||||
],
|
||||
'tesseract' => [
|
||||
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
|
||||
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
|
||||
],
|
||||
],
|
||||
'fx' => [
|
||||
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
|
||||
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
|
||||
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
|
||||
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
|
||||
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
|
||||
'auto_fetch_on_miss' => filter_var(
|
||||
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
|
||||
FILTER_VALIDATE_BOOL
|
||||
),
|
||||
],
|
||||
'debug' => [
|
||||
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
|
||||
],
|
||||
];
|
||||
17
modules/mining-checker/design.json
Normal file
17
modules/mining-checker/design.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Mining-Checker",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/modules" },
|
||||
{ "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" }
|
||||
],
|
||||
"sections": [
|
||||
{ "key": "overview", "label": "Übersicht" },
|
||||
{ "key": "upload", "label": "Upload" },
|
||||
{ "key": "measurements", "label": "Mining-History" },
|
||||
{ "key": "wallet", "label": "Wallet" },
|
||||
{ "key": "mining", "label": "Miner-Daten" },
|
||||
{ "key": "dashboards", "label": "Dashboards" }
|
||||
]
|
||||
}
|
||||
57
modules/mining-checker/desktop.php
Normal file
57
modules/mining-checker/desktop.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$sections = array_values(array_filter(
|
||||
is_array($design['sections'] ?? null) ? $design['sections'] : [],
|
||||
static fn (mixed $section): bool => is_array($section)
|
||||
));
|
||||
|
||||
return [
|
||||
'app_id' => 'mining-checker',
|
||||
'title' => (string) ($manifest['title'] ?? 'Mining-Checker'),
|
||||
'icon' => 'MC',
|
||||
'entry_route' => '/apps/mining-checker',
|
||||
'window_mode' => 'window',
|
||||
'content_mode' => 'native-module',
|
||||
'default_width' => 1180,
|
||||
'default_height' => 760,
|
||||
'supports_widget' => false,
|
||||
'supports_tray' => true,
|
||||
'module_name' => 'finance',
|
||||
'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'),
|
||||
'native_module' => [
|
||||
'initializer' => 'initMiningCheckerApp',
|
||||
'options' => [
|
||||
'apiBase' => '/api/mining-checker/index.php/v1',
|
||||
'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'activeView' => 'overview',
|
||||
'sections' => $sections,
|
||||
'moduleDebugEnabled' => false,
|
||||
],
|
||||
'assets' => [
|
||||
'styles' => [
|
||||
['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css'],
|
||||
],
|
||||
'scripts' => [
|
||||
['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true],
|
||||
['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true],
|
||||
['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
114
modules/mining-checker/docs/README.md
Normal file
114
modules/mining-checker/docs/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Mining-Checker Modul
|
||||
|
||||
## Zweck
|
||||
|
||||
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```text
|
||||
modules/mining-checker/
|
||||
|-- api/
|
||||
|-- assets/
|
||||
| |-- css/
|
||||
| `-- js/
|
||||
|-- config/
|
||||
|-- docs/
|
||||
|-- pages/
|
||||
|-- partials/
|
||||
|-- sql/
|
||||
| `-- migrations/
|
||||
|-- src/
|
||||
| |-- Api/
|
||||
| |-- Domain/
|
||||
| |-- Infrastructure/
|
||||
| `-- Support/
|
||||
|-- storage/uploads/
|
||||
|-- bootstrap.php
|
||||
`-- module.json
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
- `GET /api/mining-checker/v1/health`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
|
||||
|
||||
## Integration
|
||||
|
||||
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
|
||||
- MySQL/MariaDB: `sql/schema.mysql.sql`
|
||||
- PostgreSQL: `sql/schema.pgsql.sql`
|
||||
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
|
||||
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
|
||||
3. Modulroute ueber `/module/mining-checker` aufrufen.
|
||||
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
|
||||
|
||||
Hinweis:
|
||||
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
|
||||
Seed-Daten werden dabei nicht automatisch eingespielt.
|
||||
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
|
||||
|
||||
## OCR-Hinweis
|
||||
|
||||
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
|
||||
|
||||
Empfohlene Umgebungsvariablen:
|
||||
|
||||
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
|
||||
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
|
||||
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
|
||||
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
|
||||
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
|
||||
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
|
||||
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
|
||||
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
|
||||
- `MINING_CHECKER_TESSERACT_LANG=eng`
|
||||
|
||||
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
|
||||
|
||||
## Wechselkurse und Waehrungen
|
||||
|
||||
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
|
||||
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
|
||||
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
|
||||
|
||||
Optionaler JSON-Body:
|
||||
|
||||
- `base`: Standard `EUR`
|
||||
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
|
||||
|
||||
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
|
||||
|
||||
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
|
||||
|
||||
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.
|
||||
30
modules/mining-checker/module.json
Normal file
30
modules/mining-checker/module.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Mining-Checker",
|
||||
"title": "Mining-Checker",
|
||||
"version": "0.3.0",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"sections": {
|
||||
"database": true
|
||||
},
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
|
||||
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
97
modules/mining-checker/pages/index.php
Normal file
97
modules/mining-checker/pages/index.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$sectionKeys = array_values(array_filter(array_map(
|
||||
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
|
||||
$sections
|
||||
)));
|
||||
|
||||
if ($sectionKeys === []) {
|
||||
$sectionKeys = ['overview'];
|
||||
}
|
||||
|
||||
if (!in_array($activeView, $sectionKeys, true)) {
|
||||
$activeView = $sectionKeys[0];
|
||||
}
|
||||
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mining-Checker</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--brand-accent: #14b8a6;
|
||||
--brand-accent-3: #0f766e;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mining-module-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=mining-checker&path=assets/css/app.css">
|
||||
</head>
|
||||
<body data-module-debug-enabled="0">
|
||||
<div class="mining-module-shell">
|
||||
<div
|
||||
id="mining-checker-app"
|
||||
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
|
||||
data-api-base="/api/mining-checker/index.php/v1"
|
||||
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
|
||||
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="/module-assets/index.php?module=mining-checker&path=assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
modules/mining-checker/partials/.gitkeep
Normal file
0
modules/mining-checker/partials/.gitkeep
Normal file
124
modules/mining-checker/sql/migrations/001_init.sql
Normal file
124
modules/mining-checker/sql/migrations/001_init.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
75
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
75
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal file
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET display_timezone = 'Europe/Berlin'
|
||||
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE baseline_measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE starts_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE payout_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE purchased_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_fx_fetches
|
||||
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE fetched_at IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
|
||||
SELECT *
|
||||
FROM miningcheck_cost_plans
|
||||
WHERE 1 = 0;
|
||||
|
||||
INSERT INTO miningcheck_cost_plans_legacy
|
||||
SELECT cp.*
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_cost_plans_legacy legacy
|
||||
ON legacy.id = cp.id
|
||||
WHERE legacy.id IS NULL;
|
||||
|
||||
INSERT INTO miningcheck_purchased_miners (
|
||||
project_key,
|
||||
miner_offer_id,
|
||||
purchased_at,
|
||||
label,
|
||||
runtime_months,
|
||||
mining_speed_value,
|
||||
mining_speed_unit,
|
||||
bonus_speed_value,
|
||||
bonus_speed_unit,
|
||||
total_cost_amount,
|
||||
currency,
|
||||
usd_reference_amount,
|
||||
reference_price_amount,
|
||||
reference_price_currency,
|
||||
auto_renew,
|
||||
note,
|
||||
is_active
|
||||
)
|
||||
SELECT
|
||||
cp.project_key,
|
||||
NULL AS miner_offer_id,
|
||||
cp.starts_at AS purchased_at,
|
||||
cp.label,
|
||||
cp.runtime_months,
|
||||
cp.mining_speed_value,
|
||||
cp.mining_speed_unit,
|
||||
cp.bonus_speed_value,
|
||||
cp.bonus_speed_unit,
|
||||
cp.total_cost_amount,
|
||||
cp.currency,
|
||||
CASE
|
||||
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
|
||||
ELSE NULL
|
||||
END AS usd_reference_amount,
|
||||
cp.base_price_amount AS reference_price_amount,
|
||||
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
|
||||
cp.auto_renew,
|
||||
CASE
|
||||
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
|
||||
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
|
||||
END AS note,
|
||||
cp.is_active
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_settings s
|
||||
ON s.project_key = cp.project_key
|
||||
LEFT JOIN miningcheck_purchased_miners pm
|
||||
ON pm.project_key = cp.project_key
|
||||
AND pm.miner_offer_id IS NULL
|
||||
AND pm.purchased_at = cp.starts_at
|
||||
AND pm.label = cp.label
|
||||
AND pm.total_cost_amount = cp.total_cost_amount
|
||||
AND pm.currency = cp.currency
|
||||
WHERE pm.id IS NULL;
|
||||
|
||||
DELETE FROM miningcheck_cost_plans;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_mode = 'inherit'
|
||||
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_accent = 'teal'
|
||||
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
|
||||
-- adea1766-5d1c-4c2e-98bd-5239861f745f
|
||||
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurement_rates mr
|
||||
SET owner_sub = m.owner_sub
|
||||
FROM miningcheck_measurements m
|
||||
WHERE mr.measurement_id = m.id
|
||||
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
|
||||
|
||||
UPDATE miningcheck_measurement_rates
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_targets
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_dashboard_definitions
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_settings'
|
||||
AND constraint_name = 'uq_mining_settings_project_owner'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_measurements'
|
||||
AND constraint_name = 'uq_mining_measurements_unique'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_targets'
|
||||
AND constraint_name = 'uq_mining_targets_project_label'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_dashboard_definitions'
|
||||
AND constraint_name = 'uq_mining_dashboards_project_name'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
|
||||
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
|
||||
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
|
||||
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
|
||||
|
||||
COMMIT;
|
||||
223
modules/mining-checker/sql/schema.mysql.sql
Normal file
223
modules/mining-checker/sql/schema.mysql.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
243
modules/mining-checker/sql/schema.pgsql.sql
Normal file
243
modules/mining-checker/sql/schema.pgsql.sql
Normal file
@@ -0,0 +1,243 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
baseline_measured_at TIMESTAMP NOT NULL,
|
||||
baseline_coins_total NUMERIC(20,6) NOT NULL,
|
||||
daily_cost_amount NUMERIC(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10),
|
||||
crypto_currency VARCHAR(10),
|
||||
display_timezone VARCHAR(64),
|
||||
fx_max_age_hours NUMERIC(10,2),
|
||||
module_theme_mode VARCHAR(16),
|
||||
module_theme_accent VARCHAR(16),
|
||||
preferred_currencies JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
runtime_months INTEGER NOT NULL,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
base_price_amount NUMERIC(20,8),
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
coins_total NUMERIC(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin NUMERIC(20,8),
|
||||
price_currency VARCHAR(10),
|
||||
fx_fetch_id BIGINT,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
measurement_id BIGINT NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate NUMERIC(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount NUMERIC(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount NUMERIC(20,8),
|
||||
total_value_currency VARCHAR(10),
|
||||
wallet_balance NUMERIC(28,10),
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSONB,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
|
||||
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
base_price_amount NUMERIC(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat NUMERIC(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount NUMERIC(20,8),
|
||||
reference_price_amount NUMERIC(20,8),
|
||||
reference_price_currency VARCHAR(10),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
|
||||
ON miningcheck_fx_fetches(base_currency, fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
fetch_id BIGINT NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL,
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
|
||||
ON miningcheck_fx_rates(fetch_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
|
||||
ON miningcheck_fx_rates(currency_code);
|
||||
223
modules/mining-checker/sql/schema.sql
Normal file
223
modules/mining-checker/sql/schema.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
75
modules/mining-checker/sql/seed.sql
Normal file
75
modules/mining-checker/sql/seed.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
2774
modules/mining-checker/src/Api/Router.php
Normal file
2774
modules/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
1661
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
1661
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
File diff suppressed because it is too large
Load Diff
950
modules/mining-checker/src/Domain/FxService.php
Normal file
950
modules/mining-checker/src/Domain/FxService.php
Normal file
@@ -0,0 +1,950 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Support\DebugTrace;
|
||||
|
||||
final class FxService
|
||||
{
|
||||
private ?MiningRepository $repository;
|
||||
private string $provider;
|
||||
private string $apiBaseUrl;
|
||||
private string $currenciesApiBaseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
private int $cacheTtl;
|
||||
private bool $autoFetchOnMiss;
|
||||
private array $memoryCache = [];
|
||||
private array $snapshotCache = [];
|
||||
private ?DebugTrace $debug;
|
||||
|
||||
public function __construct(
|
||||
?MiningRepository $repository = null,
|
||||
string $apiBaseUrl = 'https://currencyapi.net',
|
||||
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
||||
int $timeout = 10,
|
||||
int $cacheTtl = 21600,
|
||||
bool $autoFetchOnMiss = false,
|
||||
string $provider = 'currencyapi',
|
||||
string $apiKey = '',
|
||||
?DebugTrace $debug = null
|
||||
)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
|
||||
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
|
||||
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
|
||||
$this->apiKey = trim($apiKey);
|
||||
$this->timeout = max(2, $timeout);
|
||||
$this->cacheTtl = max(60, $cacheTtl);
|
||||
$this->autoFetchOnMiss = $autoFetchOnMiss;
|
||||
$this->debug = $debug;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->convertAt($amount, $from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
||||
if ($resolved !== null) {
|
||||
return $amount * $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($shared !== null && method_exists($shared, 'convert')) {
|
||||
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
|
||||
return is_numeric($converted) ? (float) $converted : null;
|
||||
}
|
||||
|
||||
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
|
||||
return $rate === null ? null : $amount * $rate;
|
||||
}
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->rateAt($from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
|
||||
if ($base === '' || $target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $target) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($shared !== null && method_exists($shared, 'findRate')) {
|
||||
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
|
||||
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stored = $this->storedRate($base, $target);
|
||||
if ($stored !== null) {
|
||||
$this->memoryCache[$cacheKey] = $stored;
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$cached = $this->readFileCache($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->memoryCache[$cacheKey] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->autoFetchOnMiss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->fetchAndPersistRate($base, $target);
|
||||
$this->memoryCache[$cacheKey] = $rate;
|
||||
if ($rate !== null) {
|
||||
$this->writeFileCache($cacheKey, $rate);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
if ($fetchId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->snapshotCacheKey('fetch', [
|
||||
$fetchId,
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
||||
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('latest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
||||
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('nearest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
trim($at),
|
||||
$windowMinutes ?? 0,
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
||||
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
|
||||
return $shared->refreshLatestRates($currencies, $base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
: array_values(array_unique(array_filter(array_map(
|
||||
static fn ($code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
|
||||
|
||||
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$forwardRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === '' || $targetCode === $normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
$forwardRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($updated),
|
||||
'rates' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
|
||||
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
if ($this->repository === null) {
|
||||
return $this->refreshLatestRates(null, $normalizedBase);
|
||||
}
|
||||
|
||||
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
|
||||
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
|
||||
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
|
||||
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
|
||||
|
||||
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
|
||||
$this->debug?->add('fx.latest.reuse', [
|
||||
'base' => $normalizedBase,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $latestFetch['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'reused' => true,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.refresh_required', [
|
||||
'base' => $normalizedBase,
|
||||
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
$result = $this->refreshLatestRates(null, $normalizedBase);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
|
||||
return $shared->probeLatestRates($base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
|
||||
return $shared->refreshCurrencyCatalog();
|
||||
}
|
||||
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
if ($items === []) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$synced = [];
|
||||
$sortOrder = 1000;
|
||||
|
||||
foreach ($items as $code => $name) {
|
||||
$normalizedCode = strtoupper(trim((string) $code));
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedCode === '' || $normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = [
|
||||
'code' => substr($normalizedCode, 0, 10),
|
||||
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
|
||||
'symbol' => substr($normalizedCode, 0, 8),
|
||||
'is_active' => 1,
|
||||
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
|
||||
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
|
||||
];
|
||||
|
||||
$synced[] = $currency;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
usort($synced, static function (array $left, array $right): int {
|
||||
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
|
||||
});
|
||||
|
||||
return [
|
||||
'synced_count' => count($synced),
|
||||
'currencies' => $synced,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
|
||||
return $shared->probeCurrencyCatalog();
|
||||
}
|
||||
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
private function fetchAndPersistRate(string $base, string $target): ?float
|
||||
{
|
||||
$payload = $this->fetchLatestPayload($base, [$target]);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rate = $rates[$target] ?? null;
|
||||
if (!is_numeric($rate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericRate = (float) $rate;
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
|
||||
return $numericRate;
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, $targets);
|
||||
if ($url === null) {
|
||||
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
'targets' => $targets,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.latest.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
return $this->normalizePayload($payload, $base, $targets);
|
||||
}
|
||||
|
||||
private function fetchLatestProbe(string $base): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, null);
|
||||
if ($url === null) {
|
||||
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.probe.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.latest.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.currencies.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesProbe(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function storedRate(string $base, string $target): ?float
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$direct = $this->repository->getLatestFxRate($base, $target);
|
||||
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
|
||||
return (float) $direct['rate'];
|
||||
}
|
||||
|
||||
$inverse = $this->repository->getLatestFxRate($target, $base);
|
||||
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
|
||||
return 1 / (float) $inverse['rate'];
|
||||
}
|
||||
|
||||
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
|
||||
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
|
||||
return (float) $measurementRate['rate'];
|
||||
}
|
||||
|
||||
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
|
||||
if (
|
||||
is_array($inverseMeasurementRate) &&
|
||||
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
|
||||
(float) $inverseMeasurementRate['rate'] > 0
|
||||
) {
|
||||
return 1 / (float) $inverseMeasurementRate['rate'];
|
||||
}
|
||||
|
||||
foreach (['USD', 'EUR'] as $viaBase) {
|
||||
if ($base === $viaBase || $target === $viaBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
|
||||
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
|
||||
if (
|
||||
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
|
||||
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
|
||||
(float) $fromVia['rate'] > 0
|
||||
) {
|
||||
return (float) $toVia['rate'] / (float) $fromVia['rate'];
|
||||
}
|
||||
|
||||
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
|
||||
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
|
||||
if (
|
||||
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
|
||||
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
|
||||
(float) $toViaInverse['rate'] > 0
|
||||
) {
|
||||
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistRateSet(string $base, array $rates, string $rateDate): array
|
||||
{
|
||||
$normalizedBase = strtoupper($base);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedTarget = strtoupper((string) $target);
|
||||
$normalizedRates[$normalizedTarget] = (float) $rate;
|
||||
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
|
||||
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
|
||||
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
|
||||
} catch (\Throwable) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $base, ?array $targets = null): ?string
|
||||
{
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if ($this->apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
$targets = $targets ?? $this->defaultCurrencies();
|
||||
return sprintf(
|
||||
'%s/latest?base=%s&symbols=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode(implode(',', $targets))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$allRates = $payload['rates'];
|
||||
$filteredRates = [];
|
||||
if ($targets === null) {
|
||||
foreach ($allRates as $target => $rate) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
foreach ($targets as $target) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rate = $allRates[$targetCode] ?? null;
|
||||
if (is_numeric($rate)) {
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => strtoupper((string) ($payload['base'] ?? $base)),
|
||||
'date' => $payload['updated'] ?? null,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($payload['rates'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
$flat = [];
|
||||
array_walk_recursive($errors, static function ($value) use (&$flat): void {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$flat[] = trim($value);
|
||||
}
|
||||
});
|
||||
if ($flat !== []) {
|
||||
return implode(' | ', array_values(array_unique($flat)));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function defaultCurrencies(): array
|
||||
{
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
private function parseStoredUtcTimestamp(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
|
||||
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
|
||||
} else {
|
||||
$date = new \DateTimeImmutable($normalized);
|
||||
}
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function catalogSortOrder(string $code, int $fallback): int
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'EUR' => 10,
|
||||
'USD' => 20,
|
||||
'DOGE' => 30,
|
||||
'BTC' => 40,
|
||||
'ETH' => 50,
|
||||
'USDT' => 60,
|
||||
'USDC' => 70,
|
||||
default => $fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private function isCryptoCode(string $code): bool
|
||||
{
|
||||
return in_array(strtoupper($code), [
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XRP',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function cacheFile(string $cacheKey): string
|
||||
{
|
||||
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
|
||||
}
|
||||
|
||||
private function readFileCache(string $cacheKey): ?float
|
||||
{
|
||||
$file = $this->cacheFile($cacheKey);
|
||||
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
$rate = $payload['rate'] ?? null;
|
||||
return is_numeric($rate) ? (float) $rate : null;
|
||||
}
|
||||
|
||||
private function writeFileCache(string $cacheKey, float $rate): void
|
||||
{
|
||||
@file_put_contents($this->cacheFile($cacheKey), json_encode([
|
||||
'rate' => $rate,
|
||||
'cached_at' => time(),
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function maskUrl(string $url): string
|
||||
{
|
||||
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
|
||||
$key = $matches[2] ?? '';
|
||||
if (strlen($key) <= 8) {
|
||||
return $matches[1] . $key;
|
||||
}
|
||||
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
|
||||
private function sharedFxService(): ?object
|
||||
{
|
||||
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
return is_object($service) ? $service : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
|
||||
if ($base === '' || $from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
return (float) $rates[$to];
|
||||
}
|
||||
|
||||
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
return 1 / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
return (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function snapshotCacheKey(string $prefix, array $parts): string
|
||||
{
|
||||
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
|
||||
}
|
||||
|
||||
private function normalizeSymbolsForCache(?array $symbols): string
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
|
||||
$symbols
|
||||
))));
|
||||
sort($normalized);
|
||||
return implode(',', $normalized);
|
||||
}
|
||||
}
|
||||
665
modules/mining-checker/src/Domain/OcrService.php
Normal file
665
modules/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,665 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText(
|
||||
$rawText,
|
||||
(string) ($input['date_context'] ?? date('Y-m-d')),
|
||||
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
|
||||
);
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$measurement = $this->parseMeasurementText($rawText, $dateContext);
|
||||
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
|
||||
|
||||
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
|
||||
&& (
|
||||
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|
||||
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
|
||||
);
|
||||
|
||||
return [
|
||||
'kind' => $isWallet ? 'wallet' : 'measurement',
|
||||
'suggested' => $measurement['suggested'],
|
||||
'suggested_wallet' => $wallet['suggested_wallet'],
|
||||
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
|
||||
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMeasurementText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
foreach ($lines as $line) {
|
||||
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_line';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_context';
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
|
||||
if ($item['precision'] < 4) {
|
||||
return false;
|
||||
}
|
||||
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
|
||||
return false;
|
||||
}
|
||||
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$measurementIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$measurementIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$score = $matchedFields + min(3, $measurementIndicators);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$totalValueAmount = null;
|
||||
$totalValueCurrency = null;
|
||||
$walletBalance = null;
|
||||
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
|
||||
$balances = [];
|
||||
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|
||||
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
|
||||
) {
|
||||
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
|
||||
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
|
||||
} else {
|
||||
$flags[] = 'wallet_total_missing';
|
||||
}
|
||||
|
||||
$assetRows = [];
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
$amount = round((float) str_replace(',', '.', $match[1]), 10);
|
||||
$currency = strtoupper((string) $match[2]);
|
||||
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assetRows[] = [
|
||||
'index' => $lineIndex,
|
||||
'currency' => $currency,
|
||||
'balance' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($assetRows as $assetIndex => $assetRow) {
|
||||
$currency = (string) $assetRow['currency'];
|
||||
$balanceAmount = (float) $assetRow['balance'];
|
||||
$startIndex = (int) $assetRow['index'];
|
||||
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
|
||||
? (int) $assetRows[$assetIndex + 1]['index']
|
||||
: count($lines);
|
||||
|
||||
$usdCandidates = [];
|
||||
for ($i = $startIndex; $i < $endIndex; $i++) {
|
||||
if (!isset($lines[$i])) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
|
||||
foreach ($usdMatches as $usdMatch) {
|
||||
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$priceAmount = $this->pickWalletUnitPrice(
|
||||
$balanceAmount,
|
||||
$usdCandidates,
|
||||
$totalValueCurrency === 'USD' ? $totalValueAmount : null
|
||||
);
|
||||
$balances[$currency] = [
|
||||
'balance' => $balanceAmount,
|
||||
'price_amount' => $priceAmount,
|
||||
'price_currency' => $priceAmount !== null ? 'USD' : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
|
||||
$walletCurrency = $walletCurrencyHint;
|
||||
$walletBalance = is_array($balances[$walletCurrencyHint])
|
||||
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
|
||||
: (float) $balances[$walletCurrencyHint];
|
||||
} elseif ($balances !== []) {
|
||||
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
|
||||
if (array_key_exists($preferredCurrency, $balances)) {
|
||||
$walletCurrency = $preferredCurrency;
|
||||
$walletBalance = is_array($balances[$preferredCurrency])
|
||||
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$preferredCurrency];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($walletBalance === null) {
|
||||
$firstCurrency = array_key_first($balances);
|
||||
if (is_string($firstCurrency)) {
|
||||
$walletCurrency = $firstCurrency;
|
||||
$walletBalance = is_array($balances[$firstCurrency])
|
||||
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$firstCurrency];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'wallet_balance_missing';
|
||||
}
|
||||
|
||||
$walletIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$walletIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
|
||||
if ($field !== null && $field !== '') {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
$score = $matchedFields + ($walletIndicators * 2);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
|
||||
|
||||
ksort($balances);
|
||||
|
||||
return [
|
||||
'suggested_wallet' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'total_value_amount' => $totalValueAmount,
|
||||
'total_value_currency' => $totalValueCurrency,
|
||||
'wallet_balance' => $walletBalance,
|
||||
'wallet_currency' => $walletCurrency,
|
||||
'balances_json' => $balances,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => array_values(array_unique($flags)),
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float|int> $candidates
|
||||
*/
|
||||
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float
|
||||
{
|
||||
$candidates = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): float => round((float) $value, 8),
|
||||
$candidates
|
||||
), static fn (float $value): bool => $value > 0));
|
||||
|
||||
if ($balance <= 0 || $candidates === []) {
|
||||
return null;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
$candidate = $candidates[0];
|
||||
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
|
||||
return round($candidate / $balance, 8);
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$bestPrice = null;
|
||||
$bestError = null;
|
||||
$candidateCount = count($candidates);
|
||||
|
||||
for ($i = 0; $i < $candidateCount; $i++) {
|
||||
$priceCandidate = $candidates[$i];
|
||||
for ($j = 0; $j < $candidateCount; $j++) {
|
||||
if ($i === $j) {
|
||||
continue;
|
||||
}
|
||||
$totalCandidate = $candidates[$j];
|
||||
$estimatedTotal = $balance * $priceCandidate;
|
||||
$denominator = max(abs($totalCandidate), 0.00000001);
|
||||
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
|
||||
if ($bestError === null || $error < $bestError) {
|
||||
$bestError = $error;
|
||||
$bestPrice = $priceCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
|
||||
return round($bestPrice, 8);
|
||||
}
|
||||
|
||||
return round($candidates[count($candidates) - 1], 8);
|
||||
}
|
||||
}
|
||||
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
final class SeedData
|
||||
{
|
||||
public static function projectKey(): string
|
||||
{
|
||||
return 'doge-main';
|
||||
}
|
||||
|
||||
public static function projectName(): string
|
||||
{
|
||||
return 'DOGE Mining Main';
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => '2026-03-16 01:32:00',
|
||||
'baseline_coins_total' => 27.617864,
|
||||
'daily_cost_amount' => 0.3123287671,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function currencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
|
||||
];
|
||||
}
|
||||
|
||||
public static function measurements(): array
|
||||
{
|
||||
return [
|
||||
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
];
|
||||
}
|
||||
|
||||
public static function targets(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public static function dashboards(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
62
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
62
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
|
||||
final class SeedImporter
|
||||
{
|
||||
private MiningRepository $repository;
|
||||
|
||||
public function __construct(MiningRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
public function import(string $projectKey): array
|
||||
{
|
||||
$seedProjectKey = SeedData::projectKey();
|
||||
if ($projectKey !== $seedProjectKey) {
|
||||
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
|
||||
}
|
||||
|
||||
$this->repository->ensureProject($projectKey, SeedData::projectName());
|
||||
$this->repository->saveSettings($projectKey, SeedData::settings());
|
||||
|
||||
$insertedMeasurements = 0;
|
||||
foreach (SeedData::measurements() as $measurement) {
|
||||
try {
|
||||
$this->repository->createMeasurement($projectKey, array_merge([
|
||||
'image_path' => null,
|
||||
'ocr_raw_text' => null,
|
||||
'ocr_confidence' => null,
|
||||
'ocr_flags' => null,
|
||||
], $measurement));
|
||||
$insertedMeasurements++;
|
||||
} catch (\Throwable $exception) {
|
||||
// Duplicate seeds are expected on repeated imports.
|
||||
}
|
||||
}
|
||||
|
||||
$targetCount = 0;
|
||||
foreach (SeedData::targets() as $target) {
|
||||
$this->repository->saveTarget($projectKey, $target);
|
||||
$targetCount++;
|
||||
}
|
||||
|
||||
$dashboardCount = 0;
|
||||
foreach (SeedData::dashboards() as $dashboard) {
|
||||
$this->repository->saveDashboard($projectKey, $dashboard);
|
||||
$dashboardCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'project_key' => $projectKey,
|
||||
'imported_measurements' => $insertedMeasurements,
|
||||
'historical_rows_total' => count(SeedData::measurements()),
|
||||
'targets_synced' => $targetCount,
|
||||
'dashboards_synced' => $dashboardCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\Database as AppDatabase;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(ModuleConfig $config): PDO
|
||||
{
|
||||
$moduleSettings = modules()->settings('mining-checker');
|
||||
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
|
||||
|
||||
if ($useSeparateDb) {
|
||||
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
|
||||
}
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
private static function usesSeparateDatabase(array $moduleSettings): bool
|
||||
{
|
||||
$raw = $moduleSettings['use_separate_db'] ?? false;
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $raw));
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
|
||||
}
|
||||
|
||||
private static function assertSupportedDriver(array $dbConfig): void
|
||||
{
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
|
||||
throw new ApiException(
|
||||
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
|
||||
500,
|
||||
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1401
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
1401
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function defaultProjectKey(): string
|
||||
{
|
||||
return (string) ($this->config['default_project_key'] ?? 'doge-main');
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
|
||||
}
|
||||
|
||||
public function uploadsDir(): string
|
||||
{
|
||||
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
|
||||
}
|
||||
|
||||
public function uploadsPublicPrefix(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
|
||||
}
|
||||
|
||||
public function ocr(): array
|
||||
{
|
||||
return (array) ($this->config['ocr'] ?? []);
|
||||
}
|
||||
|
||||
public function fx(): array
|
||||
{
|
||||
return (array) ($this->config['fx'] ?? []);
|
||||
}
|
||||
|
||||
public function debug(): array
|
||||
{
|
||||
return (array) ($this->config['debug'] ?? []);
|
||||
}
|
||||
|
||||
public function debugDir(): string
|
||||
{
|
||||
$debug = $this->debug();
|
||||
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
|
||||
}
|
||||
}
|
||||
1482
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
1482
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiningChecker;
|
||||
namespace Modules\MiningChecker\Legacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
15
modules/mining-checker/src/Legacy/UserScope.php
Normal file
15
modules/mining-checker/src/Legacy/UserScope.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Legacy;
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
final class UserScope
|
||||
{
|
||||
public static function current(): string
|
||||
{
|
||||
return ModuleHttp::currentUserScope();
|
||||
}
|
||||
}
|
||||
29
modules/mining-checker/src/Support/ApiException.php
Normal file
29
modules/mining-checker/src/Support/ApiException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ApiException extends RuntimeException
|
||||
{
|
||||
private int $statusCode;
|
||||
private array $context;
|
||||
|
||||
public function __construct(string $message, int $statusCode = 400, array $context = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->statusCode = $statusCode;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
35
modules/mining-checker/src/Support/DebugState.php
Normal file
35
modules/mining-checker/src/Support/DebugState.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugState
|
||||
{
|
||||
private static array $trace = [];
|
||||
private static ?string $latestFilePath = null;
|
||||
|
||||
public static function replace(array $trace): void
|
||||
{
|
||||
self::$trace = $trace;
|
||||
}
|
||||
|
||||
public static function export(): array
|
||||
{
|
||||
return self::$trace;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$trace = [];
|
||||
}
|
||||
|
||||
public static function setLatestFilePath(?string $filePath): void
|
||||
{
|
||||
self::$latestFilePath = $filePath;
|
||||
}
|
||||
|
||||
public static function latestFilePath(): ?string
|
||||
{
|
||||
return self::$latestFilePath;
|
||||
}
|
||||
}
|
||||
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugTrace
|
||||
{
|
||||
private bool $enabled;
|
||||
private array $entries = [];
|
||||
private ?string $filePath;
|
||||
|
||||
public function __construct(bool $enabled = false, ?string $filePath = null)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->filePath = $enabled ? $filePath : null;
|
||||
DebugState::replace([]);
|
||||
if ($this->enabled && $this->filePath !== null) {
|
||||
$this->persist();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function add(string $event, array $context = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entries[] = [
|
||||
'time' => date('c'),
|
||||
'event' => $event,
|
||||
'context' => $context,
|
||||
];
|
||||
DebugState::replace($this->entries);
|
||||
$this->persist();
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return $this->enabled ? $this->entries : [];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
if (!$this->enabled || $this->filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($this->filePath);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
27
modules/mining-checker/src/Support/Http.php
Normal file
27
modules/mining-checker/src/Support/Http.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '[]', true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
@@ -20,15 +20,27 @@ $assetVersion = static function (string $publicPath): string {
|
||||
return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime;
|
||||
};
|
||||
|
||||
$hasMiningChecker = false;
|
||||
$hasUserSelfManagement = false;
|
||||
$nativeModuleStyles = [];
|
||||
$nativeModuleScripts = [];
|
||||
foreach ($desktopPayload['apps'] as $desktopApp) {
|
||||
if (($desktopApp['app_id'] ?? null) === 'mining-checker') {
|
||||
$hasMiningChecker = true;
|
||||
$assets = is_array($desktopApp['native_module']['assets'] ?? null) ? $desktopApp['native_module']['assets'] : [];
|
||||
|
||||
foreach ((array) ($assets['styles'] ?? []) as $style) {
|
||||
if (!is_array($style) || trim((string) ($style['href'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($desktopApp['app_id'] ?? null) === 'user-self-management') {
|
||||
$hasUserSelfManagement = true;
|
||||
$href = (string) $style['href'];
|
||||
$nativeModuleStyles[$href] = $style;
|
||||
}
|
||||
|
||||
foreach ((array) ($assets['scripts'] ?? []) as $script) {
|
||||
if (!is_array($script) || trim((string) ($script['src'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = (string) $script['src'];
|
||||
$nativeModuleScripts[$src] = $script;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,15 +179,14 @@ $renderStartIcon = static function (array $profile): string {
|
||||
<?php if (!empty($skinProfile['js_path']) && $skinProfile['js_path'] !== $baseSkinProfile['js_path']): ?>
|
||||
<script src="<?= htmlspecialchars($assetVersion((string) $skinProfile['js_path']), ENT_QUOTES) ?>"></script>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasMiningChecker): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.css'), ENT_QUOTES) ?>">
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasUserSelfManagement): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.css'), ENT_QUOTES) ?>">
|
||||
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.js'), ENT_QUOTES) ?>"></script>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($nativeModuleStyles as $style): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars((string) $style['href'], ENT_QUOTES) ?>">
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($nativeModuleScripts as $script): ?>
|
||||
<script
|
||||
<?php if (!empty($script['crossorigin'])): ?>crossorigin<?php endif; ?>
|
||||
src="<?= htmlspecialchars((string) $script['src'], ENT_QUOTES) ?>"
|
||||
></script>
|
||||
<?php endforeach; ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,830 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\AccountGate;
|
||||
use App\KeycloakAuth;
|
||||
use MiningChecker\LegacyModuleStore;
|
||||
use MiningChecker\MiningCheckerUserScope;
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
|
||||
ModuleHttp::requireDesktopAccess($projectRoot, true);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($auth->isAuthenticated()) {
|
||||
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
|
||||
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
|
||||
|
||||
if (!($accountCheck['allowed'] ?? false)) {
|
||||
$auth->logout();
|
||||
respond(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$auth->shouldShowDesktop()) {
|
||||
respond(['error' => 'Nicht autorisiert.'], 401);
|
||||
}
|
||||
|
||||
$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current());
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/mining-checker/index.php/';
|
||||
$relativePath = str_starts_with($path, $prefix)
|
||||
? substr($path, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
$relativePath = trim($relativePath, '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200);
|
||||
}
|
||||
|
||||
if ($relativePath === 'v1/health') {
|
||||
respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200);
|
||||
}
|
||||
|
||||
if ($relativePath === 'v1/debug/latest') {
|
||||
respond(['data' => ['entries' => [], 'file' => null]], 200);
|
||||
}
|
||||
|
||||
if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $relativePath, $matches)) {
|
||||
respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
}
|
||||
|
||||
$projectKey = $matches[1];
|
||||
$resource = trim((string) ($matches[2] ?? ''), '/');
|
||||
|
||||
try {
|
||||
$project = $store->loadProject($projectKey);
|
||||
$payload = inputPayload();
|
||||
|
||||
if ($resource === 'schema-status' && $method === 'GET') {
|
||||
respond(['data' => schemaStatus()], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'initialize' && $method === 'POST') {
|
||||
respond(['data' => initializeSchema($payload)], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'upgrade' && $method === 'POST') {
|
||||
respond(['data' => upgradeSchema()], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'rebuild-preserve-core' && $method === 'POST') {
|
||||
respond(['data' => ['message' => 'JSON-Datenbasis neu aufgebaut.', 'restored' => ['projects' => 1]]], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'legacy-fx-migrate' && $method === 'POST') {
|
||||
respond(['data' => [
|
||||
'message' => 'Keine Legacy-FX-Daten vorhanden.',
|
||||
'legacy_fetches_found' => 0,
|
||||
'fx_fetches_imported' => 0,
|
||||
'fx_fetches_reused' => 0,
|
||||
'measurements_updated' => 0,
|
||||
'measurements_unresolved' => 0,
|
||||
]], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'sql-import' && $method === 'POST') {
|
||||
$file = is_array($_FILES['sql_file'] ?? null) ? $_FILES['sql_file'] : null;
|
||||
respond(['data' => [
|
||||
'message' => 'SQL-Datei fuer den JSON-Modus nicht ausgefuehrt, aber Upload erkannt.',
|
||||
'statement_count' => 0,
|
||||
'file' => (string) ($file['name'] ?? 'upload.sql'),
|
||||
]], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'connection-test' && $method === 'GET') {
|
||||
respond(['data' => [
|
||||
'driver' => 'json-store',
|
||||
'database' => 'data/mining-checker/users',
|
||||
'project_key' => $projectKey,
|
||||
]], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'fx-history' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['fx_history'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'bootstrap' && $method === 'GET') {
|
||||
$view = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
respond(['data' => bootstrapPayload($projectKey, $project, $view)], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'measurements' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['measurements'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'measurements' && $method === 'POST') {
|
||||
$measurement = createMeasurement($store, $projectKey, $project, $payload);
|
||||
respond(['data' => $measurement], 201);
|
||||
}
|
||||
|
||||
if (preg_match('~^measurements/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') {
|
||||
$project['measurements'] = array_values(array_filter(
|
||||
(array) ($project['measurements'] ?? []),
|
||||
static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1]
|
||||
));
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => ['deleted' => true]], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'measurements-import' && $method === 'POST') {
|
||||
$result = importMeasurements($store, $projectKey, $project, $payload);
|
||||
respond(['data' => $result], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'wallet-snapshots' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['wallet_snapshots'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'wallet-snapshots' && $method === 'POST') {
|
||||
$snapshot = createWalletSnapshot($store, $projectKey, $project, $payload);
|
||||
respond(['data' => $snapshot], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'ocr-preview' && $method === 'POST') {
|
||||
respond(['data' => ocrPreview($payload)], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'settings' && $method === 'GET') {
|
||||
respond(['data' => buildSettingsPayload($project)], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'settings' && $method === 'PUT') {
|
||||
$project['settings'] = array_replace((array) $project['settings'], normalizeSettingsPayload($payload));
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => buildSettingsPayload($project)], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'targets' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['targets'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'targets' && $method === 'POST') {
|
||||
$target = normalizeTarget($payload);
|
||||
$target['id'] = $store->nextId($projectKey, 'targets');
|
||||
$project['targets'][] = $target;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $target], 201);
|
||||
}
|
||||
|
||||
if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') {
|
||||
foreach ($project['targets'] as &$target) {
|
||||
if ((int) ($target['id'] ?? 0) === (int) $idMatch[1]) {
|
||||
$target = array_replace($target, normalizeTarget($payload, false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($target);
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => findById((array) $project['targets'], (int) $idMatch[1])], 200);
|
||||
}
|
||||
|
||||
if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') {
|
||||
$project['targets'] = array_values(array_filter(
|
||||
(array) ($project['targets'] ?? []),
|
||||
static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1]
|
||||
));
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => ['deleted' => true]], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'dashboards' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['dashboards'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'dashboards' && $method === 'POST') {
|
||||
$dashboard = [
|
||||
'id' => $store->nextId($projectKey, 'dashboards'),
|
||||
'name' => trim((string) ($payload['name'] ?? 'Dashboard')),
|
||||
'chart_type' => trim((string) ($payload['chart_type'] ?? 'line')),
|
||||
'x_field' => trim((string) ($payload['x_field'] ?? 'measured_at')),
|
||||
'y_field' => trim((string) ($payload['y_field'] ?? 'coins_total')),
|
||||
'aggregation' => trim((string) ($payload['aggregation'] ?? 'none')),
|
||||
'filters' => is_array($payload['filters'] ?? null) ? $payload['filters'] : ['source' => '', 'currency' => ''],
|
||||
];
|
||||
$project['dashboards'][] = $dashboard;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $dashboard], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'dashboard-data' && $method === 'GET') {
|
||||
respond(['data' => dashboardData($project, $_GET)], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'cost-plans' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['cost_plans'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'cost-plans' && $method === 'POST') {
|
||||
$plan = normalizeCostPlan($payload);
|
||||
$plan['id'] = $store->nextId($projectKey, 'cost_plans');
|
||||
$project['cost_plans'][] = $plan;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $plan], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'payouts' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['payouts'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'payouts' && $method === 'POST') {
|
||||
$payout = [
|
||||
'id' => $store->nextId($projectKey, 'payouts'),
|
||||
'payout_at' => normalizeDateTime((string) ($payload['payout_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'coins_amount' => (float) ($payload['coins_amount'] ?? 0),
|
||||
'payout_currency' => strtoupper(trim((string) ($payload['payout_currency'] ?? 'DOGE'))),
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
];
|
||||
$project['payouts'][] = $payout;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $payout], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'miner-offers' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['miner_offers'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if ($resource === 'miner-offers' && $method === 'POST') {
|
||||
$offer = normalizeMinerOffer($payload);
|
||||
$offer['id'] = $store->nextId($projectKey, 'miner_offers');
|
||||
$project['miner_offers'][] = $offer;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $offer], 201);
|
||||
}
|
||||
|
||||
if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $idMatch) && $method === 'POST') {
|
||||
$offer = findById((array) $project['miner_offers'], (int) $idMatch[1]);
|
||||
if ($offer === null) {
|
||||
respond(['error' => 'Miner-Angebot nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
$miner = normalizePurchasedMiner($payload, $offer);
|
||||
$miner['id'] = $store->nextId($projectKey, 'purchased_miners');
|
||||
$project['purchased_miners'][] = $miner;
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => $miner], 201);
|
||||
}
|
||||
|
||||
if ($resource === 'purchased-miners' && $method === 'GET') {
|
||||
respond(['data' => array_values($project['purchased_miners'] ?? [])], 200);
|
||||
}
|
||||
|
||||
if (preg_match('~^purchased-miners/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') {
|
||||
foreach ($project['purchased_miners'] as &$miner) {
|
||||
if ((int) ($miner['id'] ?? 0) === (int) $idMatch[1]) {
|
||||
$miner['auto_renew'] = (bool) ($payload['auto_renew'] ?? $miner['auto_renew'] ?? false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($miner);
|
||||
$store->saveProject($projectKey, $project);
|
||||
respond(['data' => findById((array) $project['purchased_miners'], (int) $idMatch[1])], 200);
|
||||
}
|
||||
|
||||
respond(['error' => 'Ressource oder Methode nicht implementiert.'], 404);
|
||||
} catch (Throwable $throwable) {
|
||||
respond(['error' => $throwable->getMessage()], 500);
|
||||
}
|
||||
|
||||
function respond(array $payload, int $status): never
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function inputPayload(): array
|
||||
{
|
||||
if (stripos((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/json') !== false) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode($raw !== false ? $raw : '', true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function bootstrapPayload(string $projectKey, array $project, string $view): array
|
||||
{
|
||||
$measurements = array_values($project['measurements'] ?? []);
|
||||
usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? '')));
|
||||
$walletSnapshots = array_values($project['wallet_snapshots'] ?? []);
|
||||
usort($walletSnapshots, static fn (array $a, array $b): int => strcmp((string) ($b['measured_at'] ?? ''), (string) ($a['measured_at'] ?? '')));
|
||||
$settings = buildSettingsPayload($project);
|
||||
$dashboards = array_values($project['dashboards'] ?? []);
|
||||
$targets = array_values($project['targets'] ?? []);
|
||||
$payouts = array_values($project['payouts'] ?? []);
|
||||
$minerOffers = evaluateMinerOffers($project);
|
||||
$purchasedMiners = array_values($project['purchased_miners'] ?? []);
|
||||
$latestMeasurement = $measurements === [] ? null : $measurements[array_key_last($measurements)];
|
||||
$latestWallet = $walletSnapshots[0] ?? null;
|
||||
$walletBalances = is_array($latestWallet['balances_json'] ?? null) ? $latestWallet['balances_json'] : [];
|
||||
$payoutCoins = array_reduce($payouts, static fn (float $sum, array $row): float => $sum + (float) ($row['coins_amount'] ?? 0), 0.0);
|
||||
$latestCoins = (float) ($latestMeasurement['coins_total'] ?? 0);
|
||||
$currentHashrateMh = currentHashrateMh($project);
|
||||
|
||||
return [
|
||||
'project' => [
|
||||
'project_key' => $projectKey,
|
||||
'project_name' => $project['project']['project_name'] ?? strtoupper($projectKey),
|
||||
],
|
||||
'settings' => $settings,
|
||||
'measurements' => $measurements,
|
||||
'targets' => $targets,
|
||||
'dashboards' => $dashboards,
|
||||
'wallet_snapshots' => $walletSnapshots,
|
||||
'fx_snapshots' => [],
|
||||
'bootstrap_meta' => [
|
||||
'view' => $view,
|
||||
'overview_window_days' => 15,
|
||||
],
|
||||
'summary' => [
|
||||
'latest_measurement' => $latestMeasurement,
|
||||
'baseline' => $settings,
|
||||
'targets' => $targets,
|
||||
'current_hashrate_mh' => $currentHashrateMh,
|
||||
'miner_offers' => $minerOffers,
|
||||
'payouts' => [
|
||||
'total_count' => count($payouts),
|
||||
'total_coins' => $payoutCoins,
|
||||
'current_visible_coins' => $latestCoins,
|
||||
'current_effective_coins' => $latestCoins + $payoutCoins,
|
||||
'wallet_balances' => $walletBalances,
|
||||
'wallet_balance_current_asset' => (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0),
|
||||
'holdings_current_asset' => $latestCoins + (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function buildSettingsPayload(array $project): array
|
||||
{
|
||||
$settings = array_replace([
|
||||
'baseline_measured_at' => gmdate('Y-m-d H:i:s'),
|
||||
'baseline_coins_total' => 0,
|
||||
'daily_cost_amount' => 0,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'report_currency' => 'EUR',
|
||||
'crypto_currency' => 'DOGE',
|
||||
'display_timezone' => 'Europe/Berlin',
|
||||
'fx_max_age_hours' => 3,
|
||||
'module_theme_mode' => 'inherit',
|
||||
'module_theme_accent' => 'teal',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
], (array) ($project['settings'] ?? []));
|
||||
|
||||
$settings['cost_plans'] = array_values($project['cost_plans'] ?? []);
|
||||
$settings['payouts'] = array_values($project['payouts'] ?? []);
|
||||
$settings['miner_offers'] = array_values($project['miner_offers'] ?? []);
|
||||
$settings['purchased_miners'] = array_values($project['purchased_miners'] ?? []);
|
||||
$settings['measurement_rates'] = [];
|
||||
$settings['currencies'] = defaultCurrencies();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeSettingsPayload(array $payload): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => normalizeDateTime((string) ($payload['baseline_measured_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'baseline_coins_total' => (float) ($payload['baseline_coins_total'] ?? 0),
|
||||
'daily_cost_amount' => (float) ($payload['daily_cost_amount'] ?? 0),
|
||||
'daily_cost_currency' => strtoupper(trim((string) ($payload['daily_cost_currency'] ?? 'EUR'))),
|
||||
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? 'EUR'))),
|
||||
'crypto_currency' => strtoupper(trim((string) ($payload['crypto_currency'] ?? 'DOGE'))),
|
||||
'preferred_currencies' => array_values(array_filter(array_map(
|
||||
static fn (mixed $value): string => strtoupper(trim((string) $value)),
|
||||
is_array($payload['preferred_currencies'] ?? null) ? $payload['preferred_currencies'] : []
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
function createMeasurement(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
|
||||
{
|
||||
$measurement = [
|
||||
'id' => $store->nextId($projectKey, 'measurements'),
|
||||
'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'coins_total' => (float) ($payload['coins_total'] ?? 0),
|
||||
'coin_currency' => strtoupper(trim((string) ($payload['coin_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))),
|
||||
'price_per_coin' => (float) ($payload['price_per_coin'] ?? 0),
|
||||
'price_currency' => strtoupper(trim((string) ($payload['price_currency'] ?? $project['settings']['report_currency'] ?? 'EUR'))),
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
'source' => trim((string) ($payload['source'] ?? 'manual')),
|
||||
'image_path' => trim((string) ($payload['image_path'] ?? '')),
|
||||
'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')),
|
||||
'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0),
|
||||
'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [],
|
||||
];
|
||||
$project['measurements'][] = $measurement;
|
||||
$store->saveProject($projectKey, $project);
|
||||
|
||||
return $measurement;
|
||||
}
|
||||
|
||||
function createWalletSnapshot(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
|
||||
{
|
||||
$balances = is_array($payload['balances_json'] ?? null) ? $payload['balances_json'] : [];
|
||||
$snapshot = [
|
||||
'id' => $store->nextId($projectKey, 'wallet_snapshots'),
|
||||
'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'total_value_amount' => (float) ($payload['total_value_amount'] ?? 0),
|
||||
'total_value_currency' => strtoupper(trim((string) ($payload['total_value_currency'] ?? 'EUR'))),
|
||||
'wallet_balance' => (float) ($payload['wallet_balance'] ?? 0),
|
||||
'wallet_currency' => strtoupper(trim((string) ($payload['wallet_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))),
|
||||
'balances_json' => $balances,
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
'source' => trim((string) ($payload['source'] ?? 'manual')),
|
||||
'image_path' => trim((string) ($payload['image_path'] ?? '')),
|
||||
'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')),
|
||||
'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0),
|
||||
'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [],
|
||||
];
|
||||
$project['wallet_snapshots'][] = $snapshot;
|
||||
$store->saveProject($projectKey, $project);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function importMeasurements(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
|
||||
{
|
||||
$rowsText = (string) ($payload['rows_text'] ?? '');
|
||||
$defaultCurrency = strtoupper(trim((string) ($payload['default_currency'] ?? 'USD')));
|
||||
$source = trim((string) ($payload['source'] ?? 'manual-import'));
|
||||
$lines = preg_split('/\R+/', trim($rowsText)) ?: [];
|
||||
$imported = 0;
|
||||
$duplicates = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = preg_split('/[;,|\t]+/', $line) ?: [];
|
||||
if (count($parts) < 2) {
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$measuredAt = normalizeDateTime(trim((string) $parts[0]));
|
||||
$coinsTotal = (float) str_replace(',', '.', trim((string) $parts[1]));
|
||||
$price = isset($parts[2]) ? (float) str_replace(',', '.', trim((string) $parts[2])) : 0;
|
||||
$currency = isset($parts[3]) ? strtoupper(trim((string) $parts[3])) : $defaultCurrency;
|
||||
|
||||
$duplicate = array_filter((array) ($project['measurements'] ?? []), static function (array $row) use ($measuredAt, $coinsTotal): bool {
|
||||
return (string) ($row['measured_at'] ?? '') === $measuredAt && (float) ($row['coins_total'] ?? 0) === $coinsTotal;
|
||||
});
|
||||
|
||||
if ($duplicate !== []) {
|
||||
$duplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
createMeasurement($store, $projectKey, $project, [
|
||||
'measured_at' => $measuredAt,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'source' => $source,
|
||||
'note' => 'Import',
|
||||
]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'duplicates_ignored' => $duplicates,
|
||||
'error_count' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function ocrPreview(array $payload): array
|
||||
{
|
||||
$hintText = trim((string) ($payload['ocr_hint_text'] ?? $_POST['ocr_hint_text'] ?? ''));
|
||||
$dateContext = trim((string) ($payload['date_context'] ?? $_POST['date_context'] ?? date('Y-m-d')));
|
||||
preg_match('/(\d+(?:[.,]\d+)?)/', $hintText, $matches);
|
||||
$coins = isset($matches[1]) ? (float) str_replace(',', '.', $matches[1]) : 0.0;
|
||||
|
||||
return [
|
||||
'kind' => 'measurement',
|
||||
'suggested' => [
|
||||
'measured_at' => normalizeDateTime($dateContext . ' 12:00'),
|
||||
'coins_total' => $coins,
|
||||
'price_per_coin' => 0,
|
||||
'price_currency' => 'USD',
|
||||
'note' => $hintText,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'suggested_wallet' => [
|
||||
'measured_at' => normalizeDateTime($dateContext . ' 12:00'),
|
||||
'total_value_amount' => 0,
|
||||
'total_value_currency' => 'EUR',
|
||||
'wallet_balance' => $coins,
|
||||
'wallet_currency' => 'DOGE',
|
||||
'balances_json' => ['DOGE' => $coins],
|
||||
'note' => $hintText,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => $coins > 0 ? 0.42 : 0.0,
|
||||
'flags' => ['ocr_provider_missing:json-preview'],
|
||||
'image_path' => '',
|
||||
'raw_text' => $hintText,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeTarget(array $payload, bool $isNew = true): array
|
||||
{
|
||||
$target = [
|
||||
'label' => trim((string) ($payload['label'] ?? '')),
|
||||
'target_amount_fiat' => (float) ($payload['target_amount_fiat'] ?? 0),
|
||||
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))),
|
||||
'miner_offer_id' => trim((string) ($payload['miner_offer_id'] ?? '')),
|
||||
'is_active' => (bool) ($payload['is_active'] ?? true),
|
||||
'sort_order' => (int) ($payload['sort_order'] ?? 0),
|
||||
];
|
||||
|
||||
if (!$isNew) {
|
||||
return array_filter($target, static fn (mixed $value): bool => $value !== '' && $value !== null);
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeCostPlan(array $payload): array
|
||||
{
|
||||
$speedValue = (float) ($payload['mining_speed_value'] ?? 0);
|
||||
$speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s'));
|
||||
$bonusPercent = (float) ($payload['bonus_percent'] ?? 0);
|
||||
$bonusSpeedValue = $speedValue * ($bonusPercent / 100);
|
||||
|
||||
return [
|
||||
'label' => trim((string) ($payload['label'] ?? 'Miner')),
|
||||
'starts_at' => normalizeDateTime((string) ($payload['starts_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'runtime_months' => (int) ($payload['runtime_months'] ?? 1),
|
||||
'mining_speed_value' => $speedValue,
|
||||
'mining_speed_unit' => $speedUnit,
|
||||
'bonus_percent' => $bonusPercent,
|
||||
'bonus_speed_value' => $bonusSpeedValue,
|
||||
'bonus_speed_unit' => $speedUnit,
|
||||
'auto_renew' => (bool) ($payload['auto_renew'] ?? true),
|
||||
'base_price_amount' => (float) ($payload['base_price_amount'] ?? 0),
|
||||
'payment_type' => trim((string) ($payload['payment_type'] ?? 'fiat')),
|
||||
'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? 0),
|
||||
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))),
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
'is_active' => (bool) ($payload['is_active'] ?? true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizeMinerOffer(array $payload): array
|
||||
{
|
||||
$basePrice = (float) ($payload['base_price_amount'] ?? 0);
|
||||
$runtimeMonths = max(1, (int) ($payload['runtime_months'] ?? 1));
|
||||
$paymentType = trim((string) ($payload['payment_type'] ?? 'fiat'));
|
||||
$hashrateMh = $paymentType === 'crypto' ? 75 : 50;
|
||||
|
||||
return [
|
||||
'label' => trim((string) ($payload['label'] ?? 'Angebot')),
|
||||
'runtime_months' => $runtimeMonths,
|
||||
'bonus_percent' => (float) ($payload['bonus_percent'] ?? 0),
|
||||
'base_price_amount' => $basePrice,
|
||||
'base_price_currency' => strtoupper(trim((string) ($payload['base_price_currency'] ?? 'USD'))),
|
||||
'payment_type' => $paymentType,
|
||||
'auto_renew' => (bool) ($payload['auto_renew'] ?? false),
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
'is_active' => (bool) ($payload['is_active'] ?? true),
|
||||
'offer_hashrate_mh' => $hashrateMh,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $offer
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function normalizePurchasedMiner(array $payload, array $offer): array
|
||||
{
|
||||
$speedValue = (float) ($payload['mining_speed_value'] ?? $offer['offer_hashrate_mh'] ?? 0);
|
||||
$speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s'));
|
||||
$bonusPercent = (float) ($payload['bonus_percent'] ?? $offer['bonus_percent'] ?? 0);
|
||||
|
||||
return [
|
||||
'label' => trim((string) ($payload['label'] ?? $offer['label'] ?? 'Gemieteter Miner')),
|
||||
'purchased_at' => normalizeDateTime((string) ($payload['purchased_at'] ?? gmdate('Y-m-d H:i:s'))),
|
||||
'runtime_months' => (int) ($offer['runtime_months'] ?? 1),
|
||||
'mining_speed_value' => $speedValue,
|
||||
'mining_speed_unit' => $speedUnit,
|
||||
'bonus_percent' => $bonusPercent,
|
||||
'bonus_speed_value' => $speedValue * ($bonusPercent / 100),
|
||||
'bonus_speed_unit' => $speedUnit,
|
||||
'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? $offer['base_price_amount'] ?? 0),
|
||||
'currency' => strtoupper(trim((string) ($payload['currency'] ?? $offer['base_price_currency'] ?? 'USD'))),
|
||||
'reference_price_amount' => (float) ($payload['reference_price_amount'] ?? $offer['base_price_amount'] ?? 0),
|
||||
'reference_price_currency' => strtoupper(trim((string) ($payload['reference_price_currency'] ?? $offer['base_price_currency'] ?? 'USD'))),
|
||||
'auto_renew' => (bool) ($payload['auto_renew'] ?? $offer['auto_renew'] ?? false),
|
||||
'note' => trim((string) ($payload['note'] ?? '')),
|
||||
'is_active' => true,
|
||||
'miner_offer_id' => (int) ($offer['id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function evaluateMinerOffers(array $project): array
|
||||
{
|
||||
return array_values(array_map(static function (array $offer): array {
|
||||
return array_replace($offer, [
|
||||
'base_offer_id' => $offer['id'] ?? 0,
|
||||
'effective_price_amount' => (float) ($offer['base_price_amount'] ?? 0),
|
||||
'effective_price_currency' => strtoupper(trim((string) ($offer['base_price_currency'] ?? 'USD'))),
|
||||
'offer_hashrate_mh' => (float) ($offer['offer_hashrate_mh'] ?? 50),
|
||||
]);
|
||||
}, (array) ($project['miner_offers'] ?? [])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function dashboardData(array $project, array $query): array
|
||||
{
|
||||
$measurements = array_values($project['measurements'] ?? []);
|
||||
usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? '')));
|
||||
$xField = trim((string) ($query['x_field'] ?? 'measured_at'));
|
||||
$yField = trim((string) ($query['y_field'] ?? 'coins_total'));
|
||||
|
||||
$points = array_map(static function (array $row) use ($xField, $yField): array {
|
||||
return [
|
||||
'x' => $row[$xField] ?? null,
|
||||
'y' => is_numeric($row[$yField] ?? null) ? (float) $row[$yField] : null,
|
||||
];
|
||||
}, $measurements);
|
||||
|
||||
return [
|
||||
'points' => $points,
|
||||
'series' => [
|
||||
[
|
||||
'label' => $yField,
|
||||
'points' => $points,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function schemaStatus(): array
|
||||
{
|
||||
return [
|
||||
'required_tables' => ['json_store'],
|
||||
'present_tables' => ['json_store'],
|
||||
'missing_tables' => [],
|
||||
'pending_upgrades' => [],
|
||||
'present_count' => 1,
|
||||
'missing_count' => 0,
|
||||
'pending_upgrade_count' => 0,
|
||||
'all_present' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function initializeSchema(array $payload): array
|
||||
{
|
||||
return [
|
||||
'message' => !empty($payload['drop_existing']) ? 'JSON-Datenbestand wurde zurueckgesetzt.' : 'JSON-Datenbestand ist bereit.',
|
||||
'after' => schemaStatus(),
|
||||
'dropped_tables' => !empty($payload['drop_existing']) ? ['json_store'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function upgradeSchema(): array
|
||||
{
|
||||
return [
|
||||
'message' => 'Keine Schema-Upgrades fuer JSON-Speicher erforderlich.',
|
||||
'after' => schemaStatus(),
|
||||
'upgraded' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function defaultCurrencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'is_crypto' => false],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'is_crypto' => false],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'is_crypto' => true],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'is_crypto' => true],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'is_crypto' => true],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'is_crypto' => true],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'is_crypto' => true],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'is_crypto' => true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
function findById(array $rows, int $id): ?array
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['id'] ?? 0) === $id) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $project
|
||||
*/
|
||||
function currentHashrateMh(array $project): float
|
||||
{
|
||||
$costPlans = array_reduce((array) ($project['cost_plans'] ?? []), static function (float $sum, array $row): float {
|
||||
return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s'));
|
||||
}, 0.0);
|
||||
$purchased = array_reduce((array) ($project['purchased_miners'] ?? []), static function (float $sum, array $row): float {
|
||||
return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s'));
|
||||
}, 0.0);
|
||||
|
||||
return $costPlans + $purchased;
|
||||
}
|
||||
|
||||
function toMh(float $value, string $unit): float
|
||||
{
|
||||
return match (strtoupper(trim($unit))) {
|
||||
'KH/S' => $value / 1000,
|
||||
'MH/S' => $value,
|
||||
'GH/S' => $value * 1000,
|
||||
'TH/S' => $value * 1000000,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateTime(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$normalized = str_replace('T', ' ', $trimmed);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1) {
|
||||
return $normalized . ' 00:00:00';
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized) === 1) {
|
||||
return $normalized . ':00';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
require $projectRoot . '/modules/mining-checker/api/index.php';
|
||||
|
||||
@@ -3,40 +3,20 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 5) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__, 5) . '/modules/mining-checker/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\AccountGate;
|
||||
use App\KeycloakAuth;
|
||||
use MiningChecker\LegacyModuleStore;
|
||||
use MiningChecker\MiningCheckerUserScope;
|
||||
use Modules\MiningChecker\Legacy\LegacyModuleStore;
|
||||
use Modules\MiningChecker\Legacy\UserScope;
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 5);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
|
||||
ModuleHttp::requireDesktopAccess($projectRoot, true);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($auth->isAuthenticated()) {
|
||||
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
|
||||
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
|
||||
|
||||
if (!($accountCheck['allowed'] ?? false)) {
|
||||
$auth->logout();
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$auth->shouldShowDesktop()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current());
|
||||
$store = new LegacyModuleStore($projectRoot, UserScope::current());
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
|
||||
if ($method === 'GET') {
|
||||
|
||||
@@ -12,6 +12,7 @@ use Desktop\AppRegistry;
|
||||
use Desktop\AppVisibility;
|
||||
use Desktop\SkinResolver;
|
||||
use Desktop\WidgetRegistry;
|
||||
use ModulesCore\ModuleRegistry;
|
||||
|
||||
session_start();
|
||||
|
||||
@@ -27,7 +28,8 @@ if ($method === 'OPTIONS') {
|
||||
|
||||
$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak');
|
||||
$auth = new KeycloakAuth($keycloakConfig);
|
||||
$registry = new AppRegistry($projectRoot . '/config/apps.php');
|
||||
$moduleRegistry = new ModuleRegistry($projectRoot . '/modules');
|
||||
$registry = new AppRegistry($projectRoot . '/config/apps.php', $moduleRegistry->desktopApps());
|
||||
$widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php');
|
||||
$service = new UserSelfManagementService($projectRoot);
|
||||
$registrationConfig = ConfigLoader::load($projectRoot, 'registration');
|
||||
|
||||
@@ -2,128 +2,4 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\AccountGate;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
|
||||
|
||||
if ($auth->isAuthenticated()) {
|
||||
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
|
||||
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
|
||||
|
||||
if (!($accountCheck['allowed'] ?? false)) {
|
||||
$auth->logout();
|
||||
$target = (string) ($accountCheck['state'] ?? '') === 'pending'
|
||||
? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? ''))
|
||||
: '/auth/inactive/?message=' . urlencode((string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.'));
|
||||
header('Location: ' . $target, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$auth->shouldShowDesktop()) {
|
||||
header('Location: /auth/keycloak', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
$assetVersion = static function (string $publicPath) use ($projectRoot): string {
|
||||
$filesystemPath = $projectRoot . '/public' . $publicPath;
|
||||
|
||||
if (!is_file($filesystemPath)) {
|
||||
return $publicPath;
|
||||
}
|
||||
|
||||
$mtime = filemtime($filesystemPath);
|
||||
|
||||
return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime;
|
||||
};
|
||||
|
||||
$sections = [
|
||||
['key' => 'overview', 'label' => 'Übersicht'],
|
||||
['key' => 'upload', 'label' => 'Upload'],
|
||||
['key' => 'measurements', 'label' => 'Mining-History'],
|
||||
['key' => 'wallet', 'label' => 'Wallet'],
|
||||
['key' => 'mining', 'label' => 'Miner-Daten'],
|
||||
['key' => 'dashboards', 'label' => 'Dashboards'],
|
||||
];
|
||||
$defaultProjectKey = getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main';
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$sectionKeys = array_values(array_filter(array_map(
|
||||
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
|
||||
$sections
|
||||
)));
|
||||
|
||||
if ($sectionKeys === []) {
|
||||
$sectionKeys = ['overview'];
|
||||
}
|
||||
|
||||
if (!in_array($activeView, $sectionKeys, true)) {
|
||||
$activeView = $sectionKeys[0];
|
||||
}
|
||||
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mining-Checker</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--brand-accent: #14b8a6;
|
||||
--brand-accent-3: #0f766e;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mining-module-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body data-module-debug-enabled="0">
|
||||
<div class="mining-module-shell">
|
||||
<div
|
||||
id="mining-checker-app"
|
||||
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
|
||||
data-api-base="/api/mining-checker/index.php/v1"
|
||||
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
|
||||
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
require dirname(__DIR__, 3) . '/modules/mining-checker/pages/index.php';
|
||||
|
||||
@@ -1035,29 +1035,20 @@ if (payloadNode) {
|
||||
}
|
||||
|
||||
const moduleHost = node.querySelector('.window-module-host');
|
||||
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'mining-checker') {
|
||||
const moduleOptions = definition.module_options || {};
|
||||
if (typeof window.initMiningCheckerApp === 'function') {
|
||||
window.initMiningCheckerApp(moduleHost, {
|
||||
apiBase: '/api/mining-checker/index.php/v1',
|
||||
defaultProjectKey: moduleOptions.default_project_key || 'doge-main',
|
||||
activeView: moduleOptions.active_view || 'overview',
|
||||
sections: Array.isArray(moduleOptions.sections) ? moduleOptions.sections : [],
|
||||
moduleDebugEnabled: false,
|
||||
});
|
||||
} else {
|
||||
moduleHost.innerHTML = '<div class="window-module-error">Mining-Checker Assets konnten nicht initialisiert werden.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') {
|
||||
if (typeof window.initUserSelfManagementApp === 'function') {
|
||||
window.initUserSelfManagementApp(moduleHost, {
|
||||
apiBase: settingsApi,
|
||||
if (moduleHost && definition.content_mode === 'native-module') {
|
||||
const nativeModule = definition.native_module || {};
|
||||
const initializer = nativeModule.initializer;
|
||||
const baseOptions = nativeModule.options && typeof nativeModule.options === 'object' ? nativeModule.options : {};
|
||||
const moduleOptions = {
|
||||
...baseOptions,
|
||||
apiBase: baseOptions.apiBase || settingsApi,
|
||||
activeSkin,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof initializer === 'string' && typeof window[initializer] === 'function') {
|
||||
window[initializer](moduleHost, moduleOptions);
|
||||
} else {
|
||||
moduleHost.innerHTML = '<div class="window-module-error">User Self Management konnte nicht initialisiert werden.</div>';
|
||||
moduleHost.innerHTML = `<div class="window-module-error">${escapeHtml(definition.title || 'Modul')} konnte nicht initialisiert werden.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,6 +1104,7 @@ if (payloadNode) {
|
||||
entry_route: app.entry_route || '',
|
||||
content_mode: app.content_mode || 'summary',
|
||||
module_options: app.module_options || null,
|
||||
native_module: app.native_module || null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
53
public/module-assets/index.php
Normal file
53
public/module-assets/index.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/src/App/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleRegistry;
|
||||
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$module = trim((string) ($_GET['module'] ?? ''));
|
||||
$path = trim((string) ($_GET['path'] ?? ''));
|
||||
|
||||
if ($module === '' || $path === '' || str_contains($path, '..')) {
|
||||
http_response_code(400);
|
||||
exit('Invalid module asset request.');
|
||||
}
|
||||
|
||||
$registry = new ModuleRegistry($projectRoot . '/modules');
|
||||
$modulePath = $registry->modulePath($module);
|
||||
|
||||
if ($modulePath === null) {
|
||||
http_response_code(404);
|
||||
exit('Module not found.');
|
||||
}
|
||||
|
||||
$assetPath = realpath($modulePath . '/' . ltrim($path, '/'));
|
||||
$allowedRoot = realpath($modulePath);
|
||||
|
||||
if ($assetPath === false || $allowedRoot === false || !str_starts_with($assetPath, $allowedRoot . DIRECTORY_SEPARATOR) || !is_file($assetPath)) {
|
||||
http_response_code(404);
|
||||
exit('Asset not found.');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($assetPath, PATHINFO_EXTENSION));
|
||||
$contentType = match ($extension) {
|
||||
'css' => 'text/css; charset=utf-8',
|
||||
'js' => 'application/javascript; charset=utf-8',
|
||||
'json' => 'application/json; charset=utf-8',
|
||||
'svg' => 'image/svg+xml',
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
$mtime = filemtime($assetPath);
|
||||
if ($mtime !== false) {
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
header('Content-Type: ' . $contentType);
|
||||
header('Cache-Control: public, max-age=300');
|
||||
readfile($assetPath);
|
||||
@@ -11,6 +11,7 @@ use Desktop\DesktopState;
|
||||
use Desktop\SkinResolver;
|
||||
use Desktop\WidgetRegistry;
|
||||
use Desktop\WindowManager;
|
||||
use ModulesCore\ModuleRegistry;
|
||||
|
||||
final class App
|
||||
{
|
||||
@@ -26,7 +27,8 @@ final class App
|
||||
{
|
||||
$keycloakConfig = ConfigLoader::load($this->projectRoot, 'keycloak');
|
||||
$auth = new KeycloakAuth($keycloakConfig);
|
||||
$registry = new AppRegistry($this->projectRoot . '/config/apps.php');
|
||||
$moduleRegistry = new ModuleRegistry($this->projectRoot . '/modules');
|
||||
$registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps());
|
||||
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
|
||||
$skins = SkinResolver::all();
|
||||
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
|
||||
|
||||
@@ -6,7 +6,7 @@ spl_autoload_register(static function (string $class): void {
|
||||
$prefixes = [
|
||||
'App\\' => __DIR__ . '/',
|
||||
'Desktop\\' => dirname(__DIR__) . '/Desktop/',
|
||||
'MiningChecker\\' => dirname(__DIR__) . '/MiningChecker/',
|
||||
'ModulesCore\\' => dirname(__DIR__) . '/ModulesCore/',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix => $baseDir) {
|
||||
|
||||
@@ -9,11 +9,14 @@ final class AppRegistry
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $apps;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $additionalApps
|
||||
*/
|
||||
public function __construct(string $configPath, array $additionalApps = [])
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $apps */
|
||||
$apps = require $configPath;
|
||||
$this->apps = $apps;
|
||||
$this->apps = array_values(array_merge($apps, $additionalApps));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiningChecker;
|
||||
|
||||
final class MiningCheckerCalculator
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, float|null>
|
||||
*/
|
||||
public function calculate(array $settings): array
|
||||
{
|
||||
$hashrate = $this->toHashrateHps(
|
||||
(float) ($settings['hashrate'] ?? 0),
|
||||
(string) ($settings['hashrate_unit'] ?? 'MH/s')
|
||||
);
|
||||
$networkHashrate = $this->toHashrateHps(
|
||||
(float) ($settings['network_hashrate'] ?? 0),
|
||||
(string) ($settings['network_hashrate_unit'] ?? 'TH/s')
|
||||
);
|
||||
|
||||
$powerWatts = max(0.0, (float) ($settings['power_watts'] ?? 0));
|
||||
$electricityPrice = max(0.0, (float) ($settings['electricity_price'] ?? 0));
|
||||
$poolFeePercent = max(0.0, min(100.0, (float) ($settings['pool_fee_percent'] ?? 0)));
|
||||
$coinPrice = max(0.0, (float) ($settings['coin_price'] ?? 0));
|
||||
$blockReward = max(0.0, (float) ($settings['block_reward'] ?? 0));
|
||||
$blockTimeSeconds = max(1.0, (float) ($settings['block_time_seconds'] ?? 60));
|
||||
$hardwareCost = max(0.0, (float) ($settings['hardware_cost'] ?? 0));
|
||||
|
||||
$share = $networkHashrate > 0 ? $hashrate / $networkHashrate : null;
|
||||
$blocksPerDay = 86400 / $blockTimeSeconds;
|
||||
$dailyCoinsGross = $share !== null ? $share * $blocksPerDay * $blockReward : null;
|
||||
$dailyCoinsNet = $dailyCoinsGross !== null ? $dailyCoinsGross * (1 - ($poolFeePercent / 100)) : null;
|
||||
$dailyRevenue = $dailyCoinsNet !== null ? $dailyCoinsNet * $coinPrice : null;
|
||||
$dailyPowerCost = ($powerWatts / 1000) * 24 * $electricityPrice;
|
||||
$dailyProfit = $dailyRevenue !== null ? $dailyRevenue - $dailyPowerCost : null;
|
||||
$monthlyProfit = $dailyProfit !== null ? $dailyProfit * 30 : null;
|
||||
$annualProfit = $dailyProfit !== null ? $dailyProfit * 365 : null;
|
||||
$breakEvenDays = ($dailyProfit !== null && $dailyProfit > 0.0 && $hardwareCost > 0.0)
|
||||
? $hardwareCost / $dailyProfit
|
||||
: null;
|
||||
$efficiency = $hashrate > 0 ? $powerWatts / ($hashrate / 1000000) : null;
|
||||
|
||||
return [
|
||||
'hashrate_hps' => $hashrate > 0 ? $hashrate : null,
|
||||
'network_hashrate_hps' => $networkHashrate > 0 ? $networkHashrate : null,
|
||||
'share_ratio' => $share,
|
||||
'blocks_per_day' => $blocksPerDay,
|
||||
'daily_coins_gross' => $dailyCoinsGross,
|
||||
'daily_coins_net' => $dailyCoinsNet,
|
||||
'daily_revenue' => $dailyRevenue,
|
||||
'daily_power_cost' => $dailyPowerCost,
|
||||
'daily_profit' => $dailyProfit,
|
||||
'monthly_profit' => $monthlyProfit,
|
||||
'annual_profit' => $annualProfit,
|
||||
'break_even_days' => $breakEvenDays,
|
||||
'efficiency_w_per_mh' => $efficiency,
|
||||
];
|
||||
}
|
||||
|
||||
private function toHashrateHps(float $value, string $unit): float
|
||||
{
|
||||
if ($value <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$normalized = strtoupper(trim($unit));
|
||||
$multipliers = [
|
||||
'H/S' => 1,
|
||||
'KH/S' => 1000,
|
||||
'MH/S' => 1000000,
|
||||
'GH/S' => 1000000000,
|
||||
'TH/S' => 1000000000000,
|
||||
'PH/S' => 1000000000000000,
|
||||
'EH/S' => 1000000000000000000,
|
||||
];
|
||||
|
||||
$multiplier = $multipliers[$normalized] ?? 1000000;
|
||||
|
||||
return $value * $multiplier;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiningChecker;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MiningCheckerStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly string $userScope,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
$path = $this->path();
|
||||
|
||||
if (!is_file($path)) {
|
||||
return $this->defaultState();
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return $this->defaultState();
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
return is_array($data) ? array_replace_recursive($this->defaultState(), $data) : $this->defaultState();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
public function save(array $state): void
|
||||
{
|
||||
$directory = dirname($this->path());
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Mining-Checker-Verzeichnis konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Mining-Checker-Daten konnten nicht serialisiert werden.');
|
||||
}
|
||||
|
||||
if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Mining-Checker-Daten konnten nicht gespeichert werden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function path(): string
|
||||
{
|
||||
$safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest';
|
||||
|
||||
return $this->projectRoot . '/data/mining-checker/' . $safeScope . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultState(): array
|
||||
{
|
||||
return [
|
||||
'settings' => [
|
||||
'project_name' => 'DOGE Mining',
|
||||
'coin_symbol' => 'DOGE',
|
||||
'algorithm' => 'Scrypt',
|
||||
'currency' => 'EUR',
|
||||
'hashrate' => 950,
|
||||
'hashrate_unit' => 'MH/s',
|
||||
'network_hashrate' => 920,
|
||||
'network_hashrate_unit' => 'TH/s',
|
||||
'block_reward' => 10000,
|
||||
'block_time_seconds' => 60,
|
||||
'coin_price' => 0.14,
|
||||
'power_watts' => 3420,
|
||||
'electricity_price' => 0.32,
|
||||
'pool_fee_percent' => 1.5,
|
||||
'hardware_cost' => 8400,
|
||||
'notes' => '',
|
||||
],
|
||||
'history' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiningChecker;
|
||||
|
||||
final class MiningCheckerUserScope
|
||||
{
|
||||
public static function current(): string
|
||||
{
|
||||
$auth = $_SESSION['desktop_auth'] ?? null;
|
||||
|
||||
if (!is_array($auth)) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
|
||||
|
||||
return (string) ($user['sub'] ?? $user['username'] ?? 'guest');
|
||||
}
|
||||
}
|
||||
71
src/ModulesCore/ModuleHttp.php
Normal file
71
src/ModulesCore/ModuleHttp.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ModulesCore;
|
||||
|
||||
use App\AccountGate;
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
final class ModuleHttp
|
||||
{
|
||||
public static function requireDesktopAccess(string $projectRoot, bool $json = false): void
|
||||
{
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
|
||||
|
||||
if ($auth->isAuthenticated()) {
|
||||
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
|
||||
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
|
||||
|
||||
if (!($accountCheck['allowed'] ?? false)) {
|
||||
$auth->logout();
|
||||
$message = (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.');
|
||||
|
||||
if ($json) {
|
||||
self::respondJson(['error' => $message], 403);
|
||||
}
|
||||
|
||||
$target = (string) ($accountCheck['state'] ?? '') === 'pending'
|
||||
? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? ''))
|
||||
: '/auth/inactive/?message=' . urlencode($message);
|
||||
header('Location: ' . $target, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$auth->shouldShowDesktop()) {
|
||||
if ($json) {
|
||||
self::respondJson(['error' => 'Nicht autorisiert.'], 401);
|
||||
}
|
||||
|
||||
header('Location: /auth/keycloak', true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function currentUserScope(): string
|
||||
{
|
||||
$auth = $_SESSION['desktop_auth'] ?? null;
|
||||
|
||||
if (!is_array($auth)) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
|
||||
|
||||
return (string) ($user['sub'] ?? $user['username'] ?? 'guest');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private static function respondJson(array $payload, int $statusCode): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
105
src/ModulesCore/ModuleRegistry.php
Normal file
105
src/ModulesCore/ModuleRegistry.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ModulesCore;
|
||||
|
||||
final class ModuleRegistry
|
||||
{
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $desktopApps = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $modulesPath,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function desktopApps(): array
|
||||
{
|
||||
if ($this->desktopApps !== null) {
|
||||
return $this->desktopApps;
|
||||
}
|
||||
|
||||
$apps = [];
|
||||
|
||||
foreach ($this->moduleDirectories() as $moduleName => $modulePath) {
|
||||
$desktopConfigPath = $modulePath . '/desktop.php';
|
||||
if (!is_file($desktopConfigPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$desktopConfig = require $desktopConfigPath;
|
||||
if (!is_array($desktopConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest = $this->readJson($modulePath . '/module.json');
|
||||
$desktopConfig['module_id'] = $moduleName;
|
||||
$desktopConfig['title'] ??= (string) ($manifest['title'] ?? ucfirst($moduleName));
|
||||
$desktopConfig['summary'] ??= (string) ($manifest['description'] ?? '');
|
||||
$desktopConfig['required_groups'] ??= array_values(array_map(
|
||||
'strval',
|
||||
(array) ($manifest['auth']['groups'] ?? [])
|
||||
));
|
||||
$desktopConfig['enabled_by_default'] ??= (bool) ($manifest['enabled_by_default'] ?? false);
|
||||
$apps[] = $desktopConfig;
|
||||
}
|
||||
|
||||
return $this->desktopApps = $apps;
|
||||
}
|
||||
|
||||
public function modulePath(string $moduleName): ?string
|
||||
{
|
||||
$directories = $this->moduleDirectories();
|
||||
|
||||
return $directories[$moduleName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function moduleDirectories(): array
|
||||
{
|
||||
$paths = glob(rtrim($this->modulesPath, '/') . '/*', GLOB_ONLYDIR);
|
||||
if (!is_array($paths)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$directories = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$moduleName = basename($path);
|
||||
if ($moduleName === '' || $moduleName[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$directories[$moduleName] = $path;
|
||||
}
|
||||
|
||||
ksort($directories);
|
||||
|
||||
return $directories;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user