Files
CC-Manager/server/src/services/scraper.ts
T
Harlekin e65fdfc0ad Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle
Vier Punkte aus dem Betrieb.

Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie
angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt
reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch
einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde
schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer
Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet.

Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID
ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit
unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt
hat. Jetzt werden alle Kennungen des Spielers abgefragt.

Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben.
Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade
nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden,
welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv
gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt
weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt
am Beitritt und bleibt nachvollziehbar.

Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne
auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben
- koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit
Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist
folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die
Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen.

Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim
Test.
2026-09-21 00:19:48 +02:00

325 lines
9.9 KiB
TypeScript

import { ImportStatus, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest } from '../errors.js';
import {
classifyPackage,
fetchProfile,
resetRequestPace,
type ScrapedProfile,
} from '../lib/gextension.js';
import { PLANS } from './imports.js';
import { recordAudit, type ActionContext } from './audit.js';
/**
* Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je
* Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine
* älteren Käufe unter der alten Kennung stehen.
*/
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
}),
prisma.playerAlias.findMany({
where: { type: 'STEAM_ID' },
select: { playerId: true, value: true },
}),
]);
const alle = [
...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })),
...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })),
];
// Dieselbe Kennung nicht zweimal abrufen.
const gesehen = new Set<string>();
return alle.filter((eintrag) => {
if (gesehen.has(eintrag.steamId)) return false;
gesehen.add(eintrag.steamId);
return true;
});
}
/**
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
*/
export async function steamIdsOf(playerId: number): Promise<string[]> {
const [player, aliases] = await Promise.all([
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
prisma.playerAlias.findMany({
where: { playerId, type: 'STEAM_ID' },
select: { value: true },
orderBy: { id: 'asc' },
}),
]);
const alle = [
...(player?.steamId ? [player.steamId] : []),
...aliases.map((alias) => alias.value),
];
return [...new Set(alle)];
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
/// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen.
awaitingConfirmation: 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 übergebenen Spieler und legt daraus einen Import an,
* der wie ein Datei-Import in der Vorschau bestätigt 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,
awaitingConfirmation: 0,
unclear: 0,
usageCounters: 0,
};
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
let lineNo = 0;
// Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst,
// wenn GExtension es verlangt.
resetRequestPace();
// Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne
// diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren
// Import, und beim Buchen kollidieren sie miteinander.
const pending = await prisma.importRow.findMany({
where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } },
select: { externalId: true },
});
const alreadyPending = new Set(pending.map((row) => row.externalId as string));
for (const target of targets) {
let profile: ScrapedProfile;
try {
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
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;
}
if (alreadyPending.has(externalId)) {
summary.awaitingConfirmation += 1;
continue;
}
alreadyPending.add(externalId);
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,
awaitingConfirmation: summary.awaitingConfirmation,
unclear: summary.unclear,
failed: summary.failedPlayers.length,
},
});
return summary;
}
// --- Handprüfung der unklaren Einträge ---
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 Einträgen, für 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()] };
}
/**
* Löst 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 },
});
}