From c242e73c384413a246af1eca3dd279f1a531b851 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 15:42:31 +0200 Subject: [PATCH] GExtension-Abgleich mit Handpruefung fuer unklare Pakete Die Profilseiten sind ohne Login abrufbar und betten die Kaeufe als JSON ins HTML ein. Gelesen wird dieses JSON statt der gerenderten Tabelle: das ist unempfindlich gegen Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag und den Storno-Status. Ein Headless-Browser ist nicht noetig. Der Abgleich bucht nichts von sich aus. Eindeutige Treffer landen als regulaerer Import in der bestehenden Vorschau, die per package_bought_id dedupliziert. Titel, die nach CC klingen, sich aber keiner Guthaben-Art sicher zuordnen lassen, gehen in eine Handpruefung statt geraten zu werden - sichtbar als Hinweis auf der Uebersicht und als Box beim Spieler. Beachtet werden dabei: stornierte Kaeufe werden uebersprungen, der Web- Bereich enthaelt nie CC-Pakete, und ein Redesign-Titel zaehlt als Redesign, auch wenn Custom Character darin vorkommt. Letzteres war auch in den Standard-Mappings des Datei-Imports falsch herum einsortiert. Der Lauf geht bewusst nur ueber bereits erfasste Spieler und haelt eine Sekunde Abstand zwischen den Anfragen, da die Seite fremde Infrastruktur ist. Voraussetzung dafuer ist, dass SteamIDs auf eine Schreibweise gebracht werden: die Legacy-Form, die Steam3-Form und die 64-Bit-Zahl bezeichnen dieselbe Person, wurden bisher aber als verschiedene Spieler gefuehrt. Gespeichert wird jetzt immer die 64-Bit-Form, Eingabe und Suche akzeptieren alle drei. --- .../20250101000000_init/migration.sql | 33 +++ server/prisma/schema.prisma | 31 +++ server/src/app.ts | 2 + server/src/bootstrap.ts | 6 +- server/src/lib/gextension.ts | 179 ++++++++++++ server/src/routes/scraper.routes.ts | 75 +++++ server/src/services/imports.ts | 16 +- server/src/services/players.ts | 46 +++- server/src/services/scraper.ts | 256 ++++++++++++++++++ server/test/fixtures/profil.html | 24 ++ server/test/scraper.test.ts | 142 ++++++++++ web/src/components/UnmatchedBox.tsx | 91 +++++++ web/src/lib/types.ts | 35 +++ web/src/pages/Dashboard.tsx | 82 +++++- web/src/pages/PlayerDetail.tsx | 35 +++ web/src/styles.css | 16 ++ 16 files changed, 1053 insertions(+), 16 deletions(-) create mode 100644 server/src/lib/gextension.ts create mode 100644 server/src/routes/scraper.routes.ts create mode 100644 server/src/services/scraper.ts create mode 100644 server/test/fixtures/profil.html create mode 100644 server/test/scraper.test.ts create mode 100644 web/src/components/UnmatchedBox.tsx diff --git a/server/prisma/migrations/20250101000000_init/migration.sql b/server/prisma/migrations/20250101000000_init/migration.sql index b681ba7..0af8c03 100644 --- a/server/prisma/migrations/20250101000000_init/migration.sql +++ b/server/prisma/migrations/20250101000000_init/migration.sql @@ -62,10 +62,31 @@ CREATE TABLE "Player" ( "notes" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + "gextensionUsage" INTEGER, + "gextensionSyncedAt" TIMESTAMP(3), CONSTRAINT "Player_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "UnmatchedPackage" ( + "id" SERIAL NOT NULL, + "externalId" TEXT NOT NULL, + "playerId" INTEGER, + "steamId" TEXT NOT NULL, + "rawTitle" TEXT NOT NULL, + "rawTag" TEXT NOT NULL, + "amount" TEXT, + "purchasedAt" TIMESTAMP(3), + "scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved" BOOLEAN NOT NULL DEFAULT false, + "resolvedAs" TEXT, + "resolvedById" INTEGER, + "resolvedAt" TIMESTAMP(3), + + CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "CreditLedger" ( "id" SERIAL NOT NULL, @@ -195,6 +216,15 @@ CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId"); -- CreateIndex CREATE INDEX "Player_displayName_idx" ON "Player"("displayName"); +-- CreateIndex +CREATE UNIQUE INDEX "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId"); + +-- CreateIndex +CREATE INDEX "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved"); + +-- CreateIndex +CREATE INDEX "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId"); + -- CreateIndex CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId"); @@ -246,6 +276,9 @@ CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId"); -- AddForeignKey ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d3ca915..32c750b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -96,13 +96,44 @@ model Player { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich, + /// wird nie automatisch mit dem Ledger verrechnet. + gextensionUsage Int? + gextensionSyncedAt DateTime? + ledger CreditLedger[] characters Character[] orders ImportedOrder[] + unmatched UnmatchedPackage[] @@index([displayName]) } +/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner +/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht, +/// sondern im CC-Manager von Hand entschieden. +model UnmatchedPackage { + id Int @id @default(autoincrement()) + externalId String @unique + playerId Int? + player Player? @relation(fields: [playerId], references: [id], onDelete: Cascade) + steamId String + rawTitle String + rawTag String + amount String? + purchasedAt DateTime? + scrapedAt DateTime @default(now()) + + resolved Boolean @default(false) + /// CC_PACKAGE | REDESIGN_PACKAGE | REDESIGN_KULANZ | IGNORE + resolvedAs String? + resolvedById Int? + resolvedAt DateTime? + + @@index([resolved]) + @@index([playerId]) +} + /// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch). /// Der offene Stand eines Topfes ist die Summe der amounts je kind. model CreditLedger { diff --git a/server/src/app.ts b/server/src/app.ts index 204043d..a571ccc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import { creditRoutes } from './routes/credits.routes.js'; import { characterRoutes } from './routes/characters.routes.js'; import { importRoutes } from './routes/imports.routes.js'; import { adminRoutes } from './routes/admin.routes.js'; +import { scraperRoutes } from './routes/scraper.routes.js'; const here = dirname(fileURLToPath(import.meta.url)); // Im Container liegt das gebaute Frontend neben dem kompilierten Server. @@ -121,6 +122,7 @@ export async function buildApp(): Promise { await app.register(characterRoutes); await app.register(importRoutes); await app.register(adminRoutes); + await app.register(scraperRoutes); if (existsSync(webRoot)) { await app.register(fastifyStatic, { diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 267c818..1b1495b 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -6,11 +6,15 @@ import { hashPassword } from './auth/password.js'; // Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich // anpassbar, sobald die echten Produktnamen bekannt sind. +// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide +// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht +// das Redesign-Muster vorn. const DEFAULT_MAPPINGS = [ + { matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 }, { matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 }, { matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 }, { matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 }, - { matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 }, + { matchPattern: 'custom charakter', target: ProductTarget.CC_PACKAGE, priority: 13 }, ]; export interface BootstrapResult { diff --git a/server/src/lib/gextension.ts b/server/src/lib/gextension.ts new file mode 100644 index 0000000..09744d8 --- /dev/null +++ b/server/src/lib/gextension.ts @@ -0,0 +1,179 @@ +import { ProductTarget } from '@prisma/client'; +import { badRequest } from '../errors.js'; +import { normalizeSteamId } from './steamid.js'; + +/** + * Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die + * Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON + * gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster + * gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag + * und den Storno-Status. + */ + +export interface ScrapedPackage { + packageBoughtId: number; + packageName: string; + serverBundle: string; + purchasedAt: Date | null; + amount: string; + gateway: string; + revoked: boolean; +} + +export interface ScrapedProfile { + steamId64: string; + nickname: string | null; + packages: ScrapedPackage[]; +} + +const DONATION_ENTRY = /donations_data\[(\d+)\]\s*=\s*(\{.*?\});/gs; + +// "06.10.2025 20:54" +const GERMAN_DATE = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?$/; + +function parseGermanDate(value: unknown): Date | null { + if (typeof value !== 'string') return null; + const match = GERMAN_DATE.exec(value.trim()); + if (!match) return null; + + const [, day, month, year, hour, minute] = match; + const parsed = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour ?? 0), + Number(minute ?? 0), + ); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function parseProfile(html: string, steamId64: string): ScrapedProfile { + const packages: ScrapedPackage[] = []; + + for (const match of html.matchAll(DONATION_ENTRY)) { + const raw = match[2]; + if (!raw) continue; + + let entry: Record; + try { + entry = JSON.parse(raw) as Record; + } catch { + // Einzelne kaputte Zeile darf nicht den ganzen Lauf kippen. + continue; + } + + const name = typeof entry.package_name === 'string' ? entry.package_name.trim() : ''; + if (!name) continue; + + packages.push({ + packageBoughtId: Number(entry.package_bought_id ?? match[1]), + packageName: name, + serverBundle: typeof entry.serverbundle_name === 'string' ? entry.serverbundle_name : '', + purchasedAt: parseGermanDate(entry.date), + amount: typeof entry.amount === 'string' ? entry.amount : '', + gateway: typeof entry.gateway === 'string' ? entry.gateway : '', + revoked: entry.revoked === 1 || entry.revoked === true || entry.status === 0, + }); + } + + const nickname = extractNickname(html); + return { steamId64, nickname, packages }; +} + +function extractNickname(html: string): string | null { + const match = /Nickname\s*<\/td>\s*]*>([^<]+) { + const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + lastRequestAt = Date.now(); +} + +export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise { + const steamId64 = normalizeSteamId(steamId); + if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`); + + await throttle(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${PROFILE_URL}${steamId64}`, { + signal: controller.signal, + headers: { + // Ehrliche Kennzeichnung statt Browser-Tarnung. + 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)', + accept: 'text/html', + }, + }); + + if (!response.ok) { + throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`); + } + + return parseProfile(await response.text(), steamId64); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils'); + } + throw error; + } finally { + clearTimeout(timer); + } +} diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts new file mode 100644 index 0000000..4912de6 --- /dev/null +++ b/server/src/routes/scraper.routes.ts @@ -0,0 +1,75 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { requireAdmin, requireAuth } from '../auth/guard.js'; +import { notFound } from '../errors.js'; +import { prisma } from '../db.js'; +import { + knownSteamIds, + listUnmatched, + resolveUnmatched, + scrapePlayers, + unmatchedOverview, +} from '../services/scraper.js'; +import { contextOf, idParam, parseWith } from './helpers.js'; + +export async function scraperRoutes(app: FastifyInstance): Promise { + /** + * Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die + * bekannten: der Abgleich soll vorhandene Konten ergaenzen, nicht fremde + * Profile auf Vorrat einsammeln. + */ + app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => { + const body = parseWith( + z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }), + request.body ?? {}, + ); + + const all = await knownSteamIds(); + const targets = body.playerIds + ? all.filter((entry) => body.playerIds?.includes(entry.playerId)) + : all; + + return scrapePlayers(targets, contextOf(request)); + }); + + /** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */ + app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + + const player = await prisma.player.findUnique({ + where: { id }, + select: { id: true, steamId: true }, + }); + if (!player) throw notFound('Spieler'); + if (!player.steamId) { + throw notFound('SteamID des Spielers'); + } + + return scrapePlayers([{ playerId: player.id, steamId: player.steamId }], contextOf(request)); + }); + + app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => { + const query = parseWith( + z.object({ all: z.enum(['true', 'false']).default('false') }), + request.query, + ); + return { items: await listUnmatched(query.all === 'false') }; + }); + + app.get('/api/scrape/unmatched/overview', { preHandler: requireAuth }, async () => { + return unmatchedOverview(); + }); + + app.post('/api/scrape/unmatched/:id/resolve', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + resolution: z.enum(['CC_PACKAGE', 'REDESIGN_PACKAGE', 'REDESIGN_KULANZ', 'IGNORE']), + }), + request.body, + ); + + await resolveUnmatched(id, body.resolution, contextOf(request)); + return { ok: true }; + }); +} diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index 28224e0..252df19 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -5,6 +5,7 @@ import { prisma } from '../db.js'; import { badRequest, conflict, notFound } from '../errors.js'; import { buildPackageRows } from './credits.js'; import { recordAudit, type ActionContext } from './audit.js'; +import { normalizeSteamId } from '../lib/steamid.js'; export const PLANS = { BOOK: 'BOOK', @@ -228,8 +229,13 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx raw: row.raw as Record, })); + // Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt. + const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean); const playerKeys = [ - ...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)), + ...new Set(rawKeys.flatMap((key) => { + const normalized = normalizeSteamId(key); + return normalized ? [key, normalized] : [key]; + })), ]; const players = await prisma.player.findMany({ @@ -276,8 +282,12 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined); const target = productName ? matchTarget(productName, productMappings) : null; + const canonicalKey = normalizeSteamId(playerKey); const resolvedPlayerId = - bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null; + (canonicalKey ? bySteamId.get(canonicalKey) : undefined) ?? + bySteamId.get(playerKey) ?? + byName.get(playerKey.toLowerCase()) ?? + null; let plan: Plan; if (target === null) { @@ -382,7 +392,7 @@ export async function commitImport(importId: number, ctx: ActionContext) { const created = await tx.player.create({ data: { displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`, - steamId: key || null, + steamId: normalizeSteamId(key), }, }); createdPlayers.set(cacheKey, created.id); diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 677adb5..030aebd 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -4,6 +4,7 @@ import { conflict, notFound } from '../errors.js'; import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js'; import { listCharacters } from './characters.js'; import { recordAudit, type ActionContext } from './audit.js'; +import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js'; export interface PlayerInput { displayName: string; @@ -14,13 +15,17 @@ export interface PlayerInput { export async function searchPlayers(query: string, take = 50, skip = 0) { const trimmed = query.trim(); + // Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die + // 64-Bit-Form gespeichert ist. + const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null; + const where: Prisma.PlayerWhereInput = trimmed.length === 0 ? {} : { OR: [ { displayName: { contains: trimmed, mode: 'insensitive' } }, - { steamId: { contains: trimmed, mode: 'insensitive' } }, + { steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } }, { forumName: { contains: trimmed, mode: 'insensitive' } }, ], }; @@ -62,13 +67,44 @@ export async function getPlayerDetail(playerId: number) { const player = await prisma.player.findUnique({ where: { id: playerId } }); if (!player) throw notFound('Spieler'); - const [balances, characters, ledger] = await Promise.all([ + const [balances, characters, ledger, unmatched] = await Promise.all([ getBalances(playerId), listCharacters(playerId), listLedger({ playerId, take: 200 }), + prisma.unmatchedPackage.findMany({ + where: { playerId, resolved: false }, + orderBy: { scrapedAt: 'desc' }, + include: { player: { select: { id: true, displayName: true } } }, + }), ]); - return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total }; + return { + player, + balances, + characters, + ledger: ledger.items, + ledgerTotal: ledger.total, + unmatched, + }; +} + +/** + * Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe + * Spieler zwei Datensaetze an, sobald eine Quelle STEAM_0:... liefert und die + * andere die 64-Bit-Zahl. + */ +function canonicalSteamId(input: string | null | undefined): string | null { + const value = input?.trim(); + if (!value) return null; + + const normalized = normalizeSteamId(value); + if (!normalized) { + throw conflict( + 'INVALID_STEAMID', + `"${value}" sieht nach keiner SteamID aus. Erlaubt sind STEAM_0:1:..., [U:1:...] oder die 17-stellige Zahl.`, + ); + } + return normalized; } async function assertSteamIdFree(steamId: string, exceptId?: number): Promise { @@ -79,7 +115,7 @@ async function assertSteamIdFree(steamId: string, exceptId?: number): Promise { @@ -107,7 +143,7 @@ export async function updatePlayer(playerId: number, input: Partial const existing = await prisma.player.findUnique({ where: { id: playerId } }); if (!existing) throw notFound('Spieler'); - const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null; + const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId); if (steamId) await assertSteamIdFree(steamId, playerId); return prisma.$transaction(async (tx) => { diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts new file mode 100644 index 0000000..c6a90f8 --- /dev/null +++ b/server/src/services/scraper.ts @@ -0,0 +1,256 @@ +import { ImportStatus, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { badRequest } from '../errors.js'; +import { + classifyPackage, + fetchProfile, + type ScrapedProfile, +} from '../lib/gextension.js'; +import { PLANS } from './imports.js'; +import { recordAudit, type ActionContext } from './audit.js'; + +/** SteamIDs, die im CC-Manager bereits erfasst sind. */ +export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> { + const players = await prisma.player.findMany({ + where: { steamId: { not: null } }, + select: { id: true, steamId: true }, + orderBy: { id: 'asc' }, + }); + + return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })); +} + +export interface ScrapeSummary { + importId: number | null; + scannedPlayers: number; + failedPlayers: { steamId: string; reason: string }[]; + newRows: number; + duplicates: number; + unclear: number; + usageCounters: number; +} + +// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht +// in die Quere kommen. +const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`; + +/** + * Holt die Profile der uebergebenen Spieler und legt daraus einen Import an, + * der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts + * automatisch gebucht. + */ +export async function scrapePlayers( + targets: { playerId: number; steamId: string }[], + ctx: ActionContext, +): Promise { + const summary: ScrapeSummary = { + importId: null, + scannedPlayers: 0, + failedPlayers: [], + newRows: 0, + duplicates: 0, + unclear: 0, + usageCounters: 0, + }; + + const rows: Prisma.ImportRowCreateManyImportInput[] = []; + let lineNo = 0; + + for (const target of targets) { + let profile: ScrapedProfile; + try { + profile = await fetchProfile(target.steamId); + summary.scannedPlayers += 1; + } catch (error) { + summary.failedPlayers.push({ + steamId: target.steamId, + reason: error instanceof Error ? error.message : 'Unbekannter Fehler', + }); + continue; + } + + for (const pkg of profile.packages) { + const classification = classifyPackage(pkg); + const externalId = externalIdFor(pkg.packageBoughtId); + + if (classification.kind === 'usage_counter') { + await prisma.player.update({ + where: { id: target.playerId }, + data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() }, + }); + summary.usageCounters += 1; + continue; + } + + if (classification.kind === 'unclear') { + await prisma.unmatchedPackage.upsert({ + where: { externalId }, + create: { + externalId, + playerId: target.playerId, + steamId: profile.steamId64, + rawTitle: pkg.packageName, + rawTag: pkg.serverBundle, + amount: pkg.amount || null, + purchasedAt: pkg.purchasedAt, + }, + update: {}, + }); + summary.unclear += 1; + continue; + } + + if (classification.kind === 'ignore') continue; + + const alreadyBooked = await prisma.importedOrder.findUnique({ + where: { externalId }, + select: { id: true }, + }); + if (alreadyBooked) { + summary.duplicates += 1; + continue; + } + + lineNo += 1; + rows.push({ + lineNo, + externalId, + playerKey: profile.steamId64, + playerName: profile.nickname, + productName: pkg.packageName, + purchasedAt: pkg.purchasedAt, + target: classification.target, + resolvedPlayerId: target.playerId, + plan: PLANS.BOOK, + raw: { + package_name: pkg.packageName, + serverbundle_name: pkg.serverBundle, + amount: pkg.amount, + gateway: pkg.gateway, + } as Prisma.InputJsonValue, + }); + summary.newRows += 1; + } + + // Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde. + await prisma.player.update({ + where: { id: target.playerId }, + data: { gextensionSyncedAt: new Date() }, + }); + } + + if (rows.length > 0) { + const created = await prisma.import.create({ + data: { + filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`, + status: ImportStatus.PENDING, + rowCount: rows.length, + importedById: ctx.actorId, + mapping: { source: 'gextension' } as Prisma.InputJsonValue, + rows: { createMany: { data: rows } }, + }, + }); + summary.importId = created.id; + } + + await recordAudit(prisma, ctx, { + action: 'scrape.run', + targetType: 'import', + targetId: summary.importId, + details: { + scannedPlayers: summary.scannedPlayers, + newRows: summary.newRows, + duplicates: summary.duplicates, + unclear: summary.unclear, + failed: summary.failedPlayers.length, + }, + }); + + return summary; +} + +// --- Handpruefung der unklaren Eintraege --- + +export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE'; + +export async function listUnmatched(onlyOpen = true) { + return prisma.unmatchedPackage.findMany({ + where: onlyOpen ? { resolved: false } : {}, + orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }], + include: { player: { select: { id: true, displayName: true } } }, + }); +} + +/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */ +export async function unmatchedOverview() { + const open = await prisma.unmatchedPackage.findMany({ + where: { resolved: false }, + select: { playerId: true, player: { select: { displayName: true } } }, + }); + + const byPlayer = new Map(); + for (const entry of open) { + if (entry.playerId === null) continue; + const current = byPlayer.get(entry.playerId); + if (current) { + current.count += 1; + } else { + byPlayer.set(entry.playerId, { + playerId: entry.playerId, + displayName: entry.player?.displayName ?? 'Unbekannt', + count: 1, + }); + } + } + + return { total: open.length, players: [...byPlayer.values()] }; +} + +/** + * Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht + * sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch + * ein Redesign mitbringt. + */ +export async function resolveUnmatched( + id: number, + resolution: Resolution, + ctx: ActionContext, +): Promise { + const entry = await prisma.unmatchedPackage.findUnique({ where: { id } }); + if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden'); + if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden'); + + if (resolution !== 'IGNORE') { + if (entry.playerId === null) { + throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet'); + } + + const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js'); + const note = `GExtension: ${entry.rawTitle}`; + + if (resolution === 'CC_PACKAGE') { + await bookCcPackage(entry.playerId, ctx, { note }); + } else if (resolution === 'REDESIGN_PACKAGE') { + await bookRedesignPackage(entry.playerId, ctx, { note }); + } else { + await bookKulanz(entry.playerId, ctx, { note }); + } + } + + await prisma.unmatchedPackage.update({ + where: { id }, + data: { + resolved: true, + resolvedAs: resolution, + resolvedById: ctx.actorId, + resolvedAt: new Date(), + }, + }); + + await recordAudit(prisma, ctx, { + action: 'scrape.resolve', + targetType: 'unmatched_package', + targetId: id, + details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId }, + }); +} diff --git a/server/test/fixtures/profil.html b/server/test/fixtures/profil.html new file mode 100644 index 0000000..56474c9 --- /dev/null +++ b/server/test/fixtures/profil.html @@ -0,0 +1,24 @@ + + + +Profil + +

ALLGEMEIN

+ +
NicknameTestspieler
SteamID6476561198000000999
+
+

Pakete

+ + + diff --git a/server/test/scraper.test.ts b/server/test/scraper.test.ts new file mode 100644 index 0000000..b85c049 --- /dev/null +++ b/server/test/scraper.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { classifyPackage, parseProfile, type ScrapedPackage } from '../src/lib/gextension.js'; +import { looksLikeSteamId, normalizeSteamId, toLegacySteamId } from '../src/lib/steamid.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = readFileSync(join(here, 'fixtures', 'profil.html'), 'utf8'); + +function pkg(overrides: Partial): ScrapedPackage { + return { + packageBoughtId: 1, + packageName: 'Custom Character', + serverBundle: 'SCP-RP', + purchasedAt: null, + amount: '0.00 EUR', + gateway: '', + revoked: false, + ...overrides, + }; +} + +describe('Profil auslesen', () => { + const profile = parseProfile(FIXTURE, '76561198000000999'); + + it('findet alle Pakete', () => { + assert.equal(profile.packages.length, 8); + }); + + it('liest Titel, Bereich und Kaufdatum', () => { + const cc = profile.packages.find((p) => p.packageBoughtId === 9001); + assert.ok(cc); + assert.equal(cc.packageName, 'Custom Character'); + assert.equal(cc.serverBundle, 'SCP-RP'); + assert.equal(cc.purchasedAt?.getFullYear(), 2025); + assert.equal(cc.purchasedAt?.getMonth(), 9, 'Oktober'); + assert.equal(cc.purchasedAt?.getDate(), 6); + }); + + it('erkennt stornierte Kaeufe', () => { + const revoked = profile.packages.find((p) => p.packageBoughtId === 9007); + assert.equal(revoked?.revoked, true); + }); + + it('liest den Nicknamen aus der Allgemein-Box', () => { + assert.equal(profile.nickname, 'Testspieler'); + }); + + it('kommt mit kaputtem HTML klar, ohne alles zu verlieren', () => { + const broken = FIXTURE.replace( + 'donations_data[9004] = {"id"', + 'donations_data[9004] = {kaputt"id"', + ); + const result = parseProfile(broken, '76561198000000999'); + assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg'); + }); +}); + +describe('Pakete zuordnen', () => { + it('erkennt ein CC-Paket', () => { + assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character' })), { + kind: 'package', + target: 'CC_PACKAGE', + }); + }); + + it('erkennt die Schreibweise mit K', () => { + assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Charakter-Beantragung' })), { + kind: 'package', + target: 'CC_PACKAGE', + }); + }); + + it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => { + assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), { + kind: 'package', + target: 'REDESIGN_PACKAGE', + }); + }); + + it('liest den Verbrauchszaehler samt Anzahl', () => { + const result = classifyPackage( + pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }), + ); + assert.deepEqual(result, { kind: 'usage_counter', times: 3 }); + }); + + it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => { + assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore'); + assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore'); + assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore'); + }); + + it('ignoriert alles aus dem Web-Bereich', () => { + const result = classifyPackage( + pkg({ packageName: 'Ablassbrief (01.04.2026)', serverBundle: 'Web' }), + ); + assert.equal(result.kind, 'ignore'); + }); + + it('bucht ein storniertes Paket nicht', () => { + const result = classifyPackage(pkg({ packageName: 'Custom Character', revoked: true })); + assert.equal(result.kind, 'ignore'); + }); + + it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => { + assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear'); + }); +}); + +describe('SteamID-Schreibweisen', () => { + // Dieselbe (erfundene) Kennung in drei Formaten. + const legacy = 'STEAM_0:1:19867635'; + const modern = '[U:1:39735271]'; + const sixtyFour = '76561198000000999'; + + it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => { + assert.equal(normalizeSteamId(legacy), sixtyFour); + assert.equal(normalizeSteamId(modern), sixtyFour); + assert.equal(normalizeSteamId(sixtyFour), sixtyFour); + }); + + it('rechnet zurueck in die Legacy-Form', () => { + assert.equal(toLegacySteamId(sixtyFour), legacy); + }); + + it('beherrscht auch das ungerade Bit', () => { + const odd = 'STEAM_0:1:12345678'; + const asNumber = normalizeSteamId(odd); + assert.ok(asNumber); + assert.equal(toLegacySteamId(asNumber), odd); + }); + + it('weist Unsinn ab', () => { + assert.equal(normalizeSteamId('Darkbrine44'), null); + assert.equal(normalizeSteamId(''), null); + assert.equal(normalizeSteamId('12345'), null); + assert.equal(looksLikeSteamId('Darkbrine44'), false); + }); +}); diff --git a/web/src/components/UnmatchedBox.tsx b/web/src/components/UnmatchedBox.tsx new file mode 100644 index 0000000..d83d961 --- /dev/null +++ b/web/src/components/UnmatchedBox.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import Notice from './Notice'; +import { ApiError, api } from '../lib/api'; +import { formatDate } from '../lib/format'; +import type { Resolution, UnmatchedPackage } from '../lib/types'; + +const CHOICES: { value: Resolution; label: string; hint: string }[] = [ + { value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' }, + { value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' }, + { value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' }, + { value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' }, +]; + +/** + * Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden. + * Sie werden bewusst nie automatisch gebucht, sondern hier entschieden. + */ +export default function UnmatchedBox({ + items, + canResolve, + onResolved, +}: { + items: UnmatchedPackage[]; + canResolve: boolean; + onResolved: () => void; +}) { + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + if (items.length === 0) return null; + + const resolve = async (id: number, resolution: Resolution) => { + setBusyId(id); + setError(null); + try { + await api(`/api/scrape/unmatched/${id}/resolve`, { + method: 'POST', + body: { resolution }, + }); + onResolved(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden'); + } finally { + setBusyId(null); + } + }; + + return ( +
+

Ungeklaerte Eintraege ({items.length})

+ + Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign, + liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du + sie hier einordnest. + + + {error && setError(null)}>{error}} + +
    + {items.map((item) => ( +
  • +
    + {item.rawTitle} + {item.rawTag} +

    + {item.amount && `${item.amount} · `} + Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)} +

    +
    + {canResolve && ( +
    + {CHOICES.map((choice) => ( + + ))} +
    + )} +
  • + ))} +
+
+ ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index be93dfe..37c5953 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -43,6 +43,40 @@ export interface Player { forumName: string | null; notes: string | null; createdAt: string; + gextensionUsage: number | null; + gextensionSyncedAt: string | null; +} + +export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE'; + +export interface UnmatchedPackage { + id: number; + externalId: string; + playerId: number | null; + steamId: string; + rawTitle: string; + rawTag: string; + amount: string | null; + purchasedAt: string | null; + scrapedAt: string; + resolved: boolean; + resolvedAs: string | null; + player: { id: number; displayName: string } | null; +} + +export interface UnmatchedOverview { + total: number; + players: { playerId: number; displayName: string; count: number }[]; +} + +export interface ScrapeSummary { + importId: number | null; + scannedPlayers: number; + failedPlayers: { steamId: string; reason: string }[]; + newRows: number; + duplicates: number; + unclear: number; + usageCounters: number; } export interface CharacterRef { @@ -87,6 +121,7 @@ export interface PlayerDetail { characters: Character[]; ledger: LedgerEntry[]; ledgerTotal: number; + unmatched: UnmatchedPackage[]; } export interface AdminUser { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7daf6b2..cad3b3e 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -4,7 +4,7 @@ import Modal from '../components/Modal'; import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; import { useSession } from '../lib/session'; -import type { PlayerSummary } from '../lib/types'; +import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types'; interface SearchResult { total: number; @@ -26,6 +26,9 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); + const [flagged, setFlagged] = useState({ total: 0, players: [] }); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); const load = useCallback(async (search: string, signal?: AbortSignal) => { setLoading(true); @@ -42,6 +45,37 @@ export default function Dashboard() { } }, []); + const loadFlagged = useCallback(async () => { + try { + setFlagged(await api('/api/scrape/unmatched/overview')); + } catch { + // Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren. + } + }, []); + + useEffect(() => { + void loadFlagged(); + }, [loadFlagged]); + + const runSync = async () => { + setSyncing(true); + setSyncResult(null); + try { + const result = await api('/api/scrape/run', { method: 'POST', body: {} }); + const parts = [`${result.scannedPlayers} Profile geprueft`]; + if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`); + if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`); + if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); + if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`); + setSyncResult(parts.join(', ') + '.'); + await Promise.all([load(query), loadFlagged()]); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } finally { + setSyncing(false); + } + }; + // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest. useEffect(() => { const controller = new AbortController(); @@ -56,11 +90,18 @@ export default function Dashboard() {

Spieler

- {can('EDITOR') && ( - - )} +
+ {can('ADMIN') && ( + + )} + {can('EDITOR') && ( + + )} +
{error && {error}} + {syncResult && setSyncResult(null)}>{syncResult}} + + {flagged.total > 0 && ( + + + {flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '} + {flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}: + {' '} + {flagged.players.map((entry, index) => ( + + {index > 0 && ', '} + + {entry.displayName} ({entry.count}) + + + ))} + + )}
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea /> - setSteamId(event.target.value)} /> + setSteamId(event.target.value)} + placeholder="76561198... oder STEAM_0:1:..." + /> +

+ Beide Schreibweisen sind moeglich, auch [U:1:...]. + Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt. +

+ {detail.player.gextensionUsage !== null && ( +

+ GExtension meldet {detail.player.gextensionUsage} Mal verbraucht + {detail.player.gextensionSyncedAt && + ` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`} + . Nur zum Abgleich — wird nicht automatisch verrechnet. +

+ )} + + void load()} + /> + {editor && (
+ {can('ADMIN') && detail.player.steamId && ( + + )}
)} diff --git a/web/src/styles.css b/web/src/styles.css index 58d6e47..2ed5fea 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -817,3 +817,19 @@ textarea { padding: 1.25rem 1rem 3rem; } } + +/* --- GExtension-Abgleich --- */ + +.head-actions { + display: flex; + gap: 0.6rem; + flex-shrink: 0; +} + +.block-flag { + border-color: var(--warn); +} + +.usage-hint { + margin: -0.5rem 0 1.25rem; +}