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.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
await app.register(characterRoutes);
|
||||
await app.register(importRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(scraperRoutes);
|
||||
|
||||
if (existsSync(webRoot)) {
|
||||
await app.register(fastifyStatic, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
try {
|
||||
entry = JSON.parse(raw) as Record<string, unknown>;
|
||||
} 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*<td[^>]*>([^<]+)</i.exec(html);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
export type Classification =
|
||||
| { kind: 'package'; target: ProductTarget }
|
||||
| { kind: 'usage_counter'; times: number }
|
||||
| { kind: 'unclear' }
|
||||
| { kind: 'ignore' };
|
||||
|
||||
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
|
||||
const RELEVANT_BUNDLE = 'scp-rp';
|
||||
|
||||
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
|
||||
// zu werden.
|
||||
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
||||
|
||||
const USAGE_COUNTER = /cc[\s-]*paket.*genutzt/i;
|
||||
const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
|
||||
|
||||
/**
|
||||
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
||||
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
|
||||
* Redesign zaehlen, nicht als CC-Paket.
|
||||
*/
|
||||
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||
if (pkg.revoked) return { kind: 'ignore' };
|
||||
if (pkg.serverBundle.toLowerCase() !== RELEVANT_BUNDLE) return { kind: 'ignore' };
|
||||
|
||||
const title = pkg.packageName;
|
||||
|
||||
if (USAGE_COUNTER.test(title)) {
|
||||
const times = USAGE_TIMES.exec(title);
|
||||
return { kind: 'usage_counter', times: times ? Number(times[1]) : 1 };
|
||||
}
|
||||
|
||||
if (/redesign/i.test(title)) {
|
||||
return { kind: 'package', target: ProductTarget.REDESIGN_PACKAGE };
|
||||
}
|
||||
|
||||
if (/custom\s*charac?k?ter/i.test(title)) {
|
||||
return { kind: 'package', target: ProductTarget.CC_PACKAGE };
|
||||
}
|
||||
|
||||
// Klingt nach CC, passt aber in kein Muster: lieber nachfragen als raten.
|
||||
if (CC_HINT.test(title)) return { kind: 'unclear' };
|
||||
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
|
||||
const PROFILE_URL = 'https://gextension.senseigaming.de/index.php?t=user&id=';
|
||||
|
||||
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
|
||||
const MIN_REQUEST_GAP_MS = 1000;
|
||||
let lastRequestAt = 0;
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
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<ScrapedProfile> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
}));
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -79,7 +115,7 @@ async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<vo
|
||||
}
|
||||
|
||||
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||
const steamId = input.steamId?.trim() || null;
|
||||
const steamId = canonicalSteamId(input.steamId);
|
||||
if (steamId) await assertSteamIdFree(steamId);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
@@ -107,7 +143,7 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
||||
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) => {
|
||||
|
||||
@@ -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<ScrapeSummary> {
|
||||
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<number, { playerId: number; displayName: string; count: number }>();
|
||||
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<void> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Nachbau der Struktur einer GExtension-Profilseite mit erfundenen Daten.
|
||||
Die echte Seite bettet die Kaeufe genauso als donations_data-Zuweisungen ein. -->
|
||||
<html lang="de">
|
||||
<head><title>Profil</title></head>
|
||||
<body>
|
||||
<div class="box"><h3>ALLGEMEIN</h3>
|
||||
<table><tr><td>Nickname</td><td>Testspieler</td></tr>
|
||||
<tr><td>SteamID64</td><td>76561198000000999</td></tr></table>
|
||||
</div>
|
||||
<div class="box"><h3>Pakete</h3><div id="packages"></div></div>
|
||||
<script>
|
||||
var donations_data = [];
|
||||
donations_data[9001] = {"id":"","package_bought_id":9001,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:54","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||
donations_data[9002] = {"id":"","package_bought_id":9002,"amount":"15.00 EUR","package":10,"package_name":"Custom Character Redesign","steamid64_buyer":"","nick_buyer":"","date":"07.10.2025 10:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"Paypal","revoked":0,"status":1};
|
||||
donations_data[9003] = {"id":"","package_bought_id":9003,"amount":"0.00 EUR","package":11,"package_name":"Custom Charakter-Beantragung","steamid64_buyer":"","nick_buyer":"","date":"08.10.2025 11:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||
donations_data[9004] = {"id":"","package_bought_id":9004,"amount":"0.00 EUR","package":70,"package_name":"FG-Tasse | Merch","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:53","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||
donations_data[9005] = {"id":"","package_bought_id":9005,"amount":"49.67 EUR","package":80,"package_name":"Ablassbrief (01.04.2026)","steamid64_buyer":"","nick_buyer":"","date":"01.04.2026 09:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"Web","serverbundle":0,"gateway":"Paypal","revoked":0,"status":1};
|
||||
donations_data[9006] = {"id":"","package_bought_id":9006,"amount":"0.00 EUR","package":12,"package_name":"CC-Paket oder Redesign genutzt | 1 Mal","steamid64_buyer":"","nick_buyer":"","date":"09.10.2025 12:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||
donations_data[9007] = {"id":"","package_bought_id":9007,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"10.10.2025 13:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":1,"status":0};
|
||||
donations_data[9008] = {"id":"","package_bought_id":9008,"amount":"0.00 EUR","package":99,"package_name":"CC Sonderaktion Winter","steamid64_buyer":"","nick_buyer":"","date":"11.10.2025 14:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<section className="block block-flag">
|
||||
<h2>Ungeklaerte Eintraege ({items.length})</h2>
|
||||
<Notice kind="warn">
|
||||
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.
|
||||
</Notice>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
|
||||
<ul className="cc-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{item.rawTitle}</strong>
|
||||
<span className="badge">{item.rawTag}</span>
|
||||
<p className="muted small">
|
||||
{item.amount && `${item.amount} · `}
|
||||
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{canResolve && (
|
||||
<div className="cc-actions">
|
||||
{CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
|
||||
title={choice.hint}
|
||||
disabled={busyId !== null}
|
||||
onClick={() => void resolve(item.id, choice.value)}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(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<UnmatchedOverview>('/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<ScrapeSummary>('/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,12 +90,19 @@ export default function Dashboard() {
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<h1>Spieler</h1>
|
||||
<div className="head-actions">
|
||||
{can('ADMIN') && (
|
||||
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
|
||||
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
|
||||
</button>
|
||||
)}
|
||||
{can('EDITOR') && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
Spieler anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="search"
|
||||
@@ -73,6 +114,24 @@ export default function Dashboard() {
|
||||
/>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
|
||||
|
||||
{flagged.total > 0 && (
|
||||
<Notice kind="warn">
|
||||
<strong>
|
||||
{flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '}
|
||||
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
|
||||
</strong>{' '}
|
||||
{flagged.players.map((entry, index) => (
|
||||
<span key={entry.playerId}>
|
||||
{index > 0 && ', '}
|
||||
<Link to={`/players/${entry.playerId}`}>
|
||||
{entry.displayName} ({entry.count})
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
|
||||
/>
|
||||
|
||||
<label htmlFor="steamId">SteamID (optional)</label>
|
||||
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} />
|
||||
<input
|
||||
id="steamId"
|
||||
value={steamId}
|
||||
onChange={(event) => setSteamId(event.target.value)}
|
||||
placeholder="76561198... oder STEAM_0:1:..."
|
||||
/>
|
||||
<p className="muted small">
|
||||
Beide Schreibweisen sind moeglich, auch <code className="mono">[U:1:...]</code>.
|
||||
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
|
||||
</p>
|
||||
|
||||
<label htmlFor="forumName">Forum-Name (optional)</label>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import BalanceCards from '../components/BalanceCards';
|
||||
import UnmatchedBox from '../components/UnmatchedBox';
|
||||
import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
@@ -87,6 +88,21 @@ export default function PlayerDetail() {
|
||||
|
||||
<BalanceCards balances={detail.balances} />
|
||||
|
||||
{detail.player.gextensionUsage !== null && (
|
||||
<p className="muted small usage-hint">
|
||||
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
||||
{detail.player.gextensionSyncedAt &&
|
||||
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
||||
. Nur zum Abgleich — wird nicht automatisch verrechnet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<UnmatchedBox
|
||||
items={detail.unmatched ?? []}
|
||||
canResolve={can('ADMIN')}
|
||||
onResolved={() => void load()}
|
||||
/>
|
||||
|
||||
{editor && (
|
||||
<section className="action-bar">
|
||||
<button
|
||||
@@ -140,6 +156,25 @@ export default function PlayerDetail() {
|
||||
>
|
||||
Neuer CC
|
||||
</button>
|
||||
{can('ADMIN') && detail.player.steamId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
title="Pakete dieses Spielers von GExtension abrufen"
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const result = await api<{ newRows: number; unclear: number }>(
|
||||
`/api/scrape/player/${playerId}`,
|
||||
{ method: 'POST', body: {} },
|
||||
);
|
||||
return result;
|
||||
}, 'GExtension abgeglichen.')
|
||||
}
|
||||
>
|
||||
GExtension abgleichen
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user