Abgleich in der README beschreiben und Adresse konfigurierbar machen
Der Abgleich liest GExtension-Profile und ist auf deren Seitenaufbau gebaut. Damit niemand in einer anderen Community lange sucht, warum nichts passiert, steht das jetzt als Warnhinweis am Anfang des Abschnitts: bei einer anderen GExtension-Instanz reicht GEXTENSION_BASE_URL, bei einem voellig anderen Shop-System muesste der Parser angepasst werden. Der Rest der App laeuft unabhaengig davon. Die Adresse war bisher fest im Code verdrahtet und kommt jetzt aus der Umgebung. Der Parser selbst bleibt frei von Konfiguration und Datenbank, damit er ohne beides testbar ist - die Adresse reicht der Service durch.
This commit is contained in:
@@ -15,6 +15,14 @@ BOOTSTRAP_ADMIN_PASSWORD=
|
|||||||
|
|
||||||
# --- Optional ---
|
# --- Optional ---
|
||||||
|
|
||||||
|
# Adresse der GExtension-Instanz fuer den Paket-Abgleich. Der Parser ist auf
|
||||||
|
# deren Seitenaufbau gebaut; ein anderes Shop-System laesst sich damit nicht
|
||||||
|
# auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird.
|
||||||
|
# GEXTENSION_BASE_URL=https://gextension.senseigaming.de
|
||||||
|
|
||||||
|
# Aufbewahrung der Audit-Eintraege in Tagen (Standard: 365, 0 = nie loeschen).
|
||||||
|
# AUDIT_RETENTION_DAYS=365
|
||||||
|
|
||||||
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
||||||
# SESSION_TTL_HOURS=12
|
# SESSION_TTL_HOURS=12
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,47 @@ Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
|
|||||||
|
|
||||||
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
|
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
|
||||||
|
|
||||||
|
## GExtension-Abgleich
|
||||||
|
|
||||||
|
> **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
|
||||||
|
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine
|
||||||
|
> andere GExtension-Instanz, genügt `GEXTENSION_BASE_URL` in der `.env`. Nutzt ihr ein
|
||||||
|
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** — dafür müsste
|
||||||
|
> `server/src/lib/gextension.ts` angepasst werden. Der Rest der App ist davon unabhängig und
|
||||||
|
> läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
|
||||||
|
> eintragen.
|
||||||
|
|
||||||
|
Der Abgleich holt die gekauften Pakete eines Spielers direkt aus seinem GExtension-Profil,
|
||||||
|
damit sie nicht abgetippt werden müssen.
|
||||||
|
|
||||||
|
**Was er tut:** Für jeden Spieler, der im CC-Manager erfasst ist *und* eine SteamID hinterlegt
|
||||||
|
hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das
|
||||||
|
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) — jeweils als
|
||||||
|
Admin.
|
||||||
|
|
||||||
|
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie
|
||||||
|
abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach
|
||||||
|
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein — eindeutige Treffer
|
||||||
|
landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.
|
||||||
|
|
||||||
|
Erkannt wird anhand von Titel *und* Bereich: Nur der `SCP-RP`-Bereich führt CC-Pakete, und ein
|
||||||
|
Titel mit „Redesign" zählt als Redesign, auch wenn „Custom Character" darin vorkommt.
|
||||||
|
Stornierte Käufe werden übersprungen. Doppelbuchungen verhindert die Bestell-ID von GExtension.
|
||||||
|
|
||||||
|
**Ungeklärte Einträge:** Klingt ein Titel nach CC oder Redesign, passt aber in kein Muster (etwa
|
||||||
|
eine einmalige Sonderaktion), wird *nicht geraten*. Der Eintrag landet in einer Handprüfung:
|
||||||
|
sichtbar als Hinweis auf der Übersicht und als Box auf der Spielerseite, wo ein Admin ihn als
|
||||||
|
CC-Paket, gekauftes Redesign, Kulanz oder „nichts davon" einordnet. Bei CC-Paket entsteht
|
||||||
|
automatisch auch das inklusive Redesign-Guthaben.
|
||||||
|
|
||||||
|
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim
|
||||||
|
Spieler angezeigt, aber **nie** automatisch verrechnet — es ist eine Lesehilfe zum Abgleich mit
|
||||||
|
dem eigenen Ledger, mehr nicht.
|
||||||
|
|
||||||
|
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
|
||||||
|
Die Seite gehört dem Serverbetreiber, nicht dieser App — ein offizieller Datenzugang wäre der
|
||||||
|
sauberere Weg, falls er zu haben ist.
|
||||||
|
|
||||||
## Rollen
|
## Rollen
|
||||||
|
|
||||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
@@ -148,9 +189,10 @@ und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
|
|||||||
```
|
```
|
||||||
server/
|
server/
|
||||||
prisma/schema.prisma Datenmodell und Migrationen
|
prisma/schema.prisma Datenmodell und Migrationen
|
||||||
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import
|
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import, Abgleich
|
||||||
src/routes/ HTTP-Endpunkte
|
src/routes/ HTTP-Endpunkte
|
||||||
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
||||||
|
src/lib/ SteamID-Umrechnung, GExtension-Parser
|
||||||
test/ Integrationstests gegen eine echte Postgres-Instanz
|
test/ Integrationstests gegen eine echte Postgres-Instanz
|
||||||
web/
|
web/
|
||||||
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ services:
|
|||||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: "true"
|
||||||
|
# Nur noetig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
||||||
|
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
||||||
|
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
||||||
expose:
|
expose:
|
||||||
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
||||||
- "3000"
|
- "3000"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const schema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||||
|
|
||||||
|
// Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau
|
||||||
|
// zugeschnitten; eine voellig andere Software liesse sich damit nicht lesen.
|
||||||
|
GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'),
|
||||||
|
|
||||||
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
||||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ export function classifyPackage(pkg: ScrapedPackage): Classification {
|
|||||||
return { kind: 'ignore' };
|
return { kind: 'ignore' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROFILE_URL = 'https://gextension.senseigaming.de/index.php?t=user&id=';
|
// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension
|
||||||
|
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
|
||||||
|
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
|
||||||
|
|
||||||
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
|
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
|
||||||
const MIN_REQUEST_GAP_MS = 1000;
|
const MIN_REQUEST_GAP_MS = 1000;
|
||||||
@@ -144,17 +146,28 @@ async function throttle(): Promise<void> {
|
|||||||
lastRequestAt = Date.now();
|
lastRequestAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise<ScrapedProfile> {
|
export interface FetchOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(
|
||||||
|
steamId: string,
|
||||||
|
options: FetchOptions = {},
|
||||||
|
): Promise<ScrapedProfile> {
|
||||||
const steamId64 = normalizeSteamId(steamId);
|
const steamId64 = normalizeSteamId(steamId);
|
||||||
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
|
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
|
||||||
|
|
||||||
|
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
|
const timeoutMs = options.timeoutMs ?? 15000;
|
||||||
|
|
||||||
await throttle();
|
await throttle();
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${PROFILE_URL}${steamId64}`, {
|
const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ImportStatus, type Prisma } from '@prisma/client';
|
import { ImportStatus, type Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
import { badRequest } from '../errors.js';
|
import { badRequest } from '../errors.js';
|
||||||
import {
|
import {
|
||||||
classifyPackage,
|
classifyPackage,
|
||||||
@@ -59,7 +60,7 @@ export async function scrapePlayers(
|
|||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
let profile: ScrapedProfile;
|
let profile: ScrapedProfile;
|
||||||
try {
|
try {
|
||||||
profile = await fetchProfile(target.steamId);
|
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
|
||||||
summary.scannedPlayers += 1;
|
summary.scannedPlayers += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
summary.failedPlayers.push({
|
summary.failedPlayers.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user