Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch
Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket. Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der Spielerseite. Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung warten und bei wem. Ausserdem: - Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen. - Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten. - Die Hinweisbox ueber den Antraegen beim Spieler faellt weg. - Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem Tabellenlayout gefallen, daher die versetzte Trennlinie. - Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier" nach UTC und im Sommer zwei Stunden daneben.
This commit is contained in:
@@ -35,6 +35,15 @@ const schema = z.object({
|
||||
// WoltLab (Burning Board) zugeschnitten.
|
||||
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
|
||||
|
||||
// Wiederkehrende Abgleiche: Käufe alle zwei Stunden (ruhende Konten alle
|
||||
// sechs), das Forum einmal nachts. Abschaltbar, etwa für eine zweite
|
||||
// Instanz, die nicht mitlaufen soll.
|
||||
SCHEDULE_ENABLED: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
FORUM_SCAN_HOUR: z.coerce.number().int().min(0).max(23).default(4),
|
||||
|
||||
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { buildApp } from './app.js';
|
||||
import { bootstrap } from './bootstrap.js';
|
||||
import { cleanupExpiredSessions } from './auth/session.js';
|
||||
import { cleanupOldAuditEntries } from './services/audit.js';
|
||||
import { startSchedule } from './services/schedule.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
@@ -37,9 +38,16 @@ async function main(): Promise<void> {
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanup.unref();
|
||||
|
||||
const stopSchedule = startSchedule({
|
||||
info: (msg) => app.log.info(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
app.log.info(`${signal} empfangen, fahre herunter`);
|
||||
clearInterval(cleanup);
|
||||
stopSchedule();
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ForumThread {
|
||||
isMovedStub: boolean;
|
||||
/// Labels des Threads, etwa "Wichtig" oder "SCP-RP".
|
||||
labels: string[];
|
||||
/// Wann der Antrag gestellt wurde, laut Forum.
|
||||
postedAt: Date | null;
|
||||
}
|
||||
|
||||
/// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs,
|
||||
@@ -47,6 +49,11 @@ const AUTHOR_CELL = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
|
||||
// Ein zurückgelassener Verweis ist kein eigener Thread.
|
||||
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
|
||||
|
||||
// Der Zeitpunkt des ersten Beitrags - das Datum des Antrags. Die Spalte
|
||||
// daneben trägt den letzten Beitrag, der ist hier ohne Belang.
|
||||
const POSTED_AT =
|
||||
/<li class="messageGroupTime">\s*<woltlab-core-date-time[^>]*\sdate="([^"]+)"/;
|
||||
|
||||
// Labels hängen als eigene Links über dem Titel, nicht als span.
|
||||
const LABEL = /<a[^>]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g;
|
||||
|
||||
@@ -85,6 +92,9 @@ export function parseBoard(html: string): ForumThread[] {
|
||||
LABEL.lastIndex = 0;
|
||||
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
|
||||
|
||||
const zeit = POSTED_AT.exec(block);
|
||||
const postedAt = zeit ? new Date(zeit[1] as string) : null;
|
||||
|
||||
threads.push({
|
||||
threadId,
|
||||
title: decode(topic[3] ?? ''),
|
||||
@@ -93,6 +103,7 @@ export function parseBoard(html: string): ForumThread[] {
|
||||
authorForumId: user ? Number(user[1]) : null,
|
||||
isMovedStub: MOVED_MARKER.test(block),
|
||||
labels,
|
||||
postedAt: postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireAdmin } from '../auth/guard.js';
|
||||
import { badRequest, conflict, notFound } from '../errors.js';
|
||||
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
|
||||
import { destroyAllSessionsFor } from '../auth/session.js';
|
||||
import { env } from '../env.js';
|
||||
import { recordAudit } from '../services/audit.js';
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
@@ -266,6 +267,16 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// --- Audit-Log ---
|
||||
|
||||
/** Stand der wiederkehrenden Abgleiche. */
|
||||
app.get('/api/admin/schedule', async () => {
|
||||
const { listRuns } = await import('../services/schedule.js');
|
||||
return {
|
||||
enabled: env.SCHEDULE_ENABLED,
|
||||
forumHour: env.FORUM_SCAN_HOUR,
|
||||
runs: await listRuns(),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/admin/audit', async (request) => {
|
||||
const query = parseWith(
|
||||
z.object({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseUpload,
|
||||
bookRows,
|
||||
dismissRows,
|
||||
pendingOverview,
|
||||
} from '../services/imports.js';
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
@@ -43,6 +44,11 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
/** Wie viele Pakete insgesamt noch auf eine Buchung warten. */
|
||||
app.get('/api/imports/pending/overview', { preHandler: requireEditor }, async () => {
|
||||
return pendingOverview();
|
||||
});
|
||||
|
||||
app.get('/api/imports', { preHandler: requireEditor }, async () => {
|
||||
return { items: await listImports() };
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreditSource,
|
||||
InactiveReason,
|
||||
type Character,
|
||||
type Prisma,
|
||||
} from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
import { badRequest, conflict, notFound } from '../errors.js';
|
||||
@@ -37,6 +38,60 @@ function isOverdrawn(balances: Balances): boolean {
|
||||
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Slot-Regel.
|
||||
*
|
||||
* Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei
|
||||
* Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns
|
||||
* dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen
|
||||
* zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC.
|
||||
*
|
||||
* Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC
|
||||
* braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen
|
||||
* einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt
|
||||
* noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der
|
||||
* einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht
|
||||
* wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts.
|
||||
*
|
||||
* Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen,
|
||||
* redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten.
|
||||
*/
|
||||
async function assertSlotsReichen(
|
||||
tx: Prisma.TransactionClient,
|
||||
playerId: number,
|
||||
was: string,
|
||||
): Promise<void> {
|
||||
const [eintraege, belegt] = await Promise.all([
|
||||
tx.creditLedger.findMany({
|
||||
where: { playerId, kind: CreditKind.CC },
|
||||
select: { amount: true, source: true, reversalOf: { select: { source: true } } },
|
||||
}),
|
||||
tx.characterMember.count({
|
||||
where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art
|
||||
// der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener
|
||||
// Platz von einem zurückgenommenen Paket unterscheiden.
|
||||
let gebuchtePlaetze = 0;
|
||||
for (const eintrag of eintraege) {
|
||||
const zweck =
|
||||
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
|
||||
if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount;
|
||||
}
|
||||
|
||||
if (belegt > gebuchtePlaetze) {
|
||||
throw conflict(
|
||||
'NO_FREE_SLOT',
|
||||
`${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` +
|
||||
'Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht ' +
|
||||
'einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - ' +
|
||||
'oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharacter(id: number): Promise<Character> {
|
||||
const character = await prisma.character.findUnique({ where: { id } });
|
||||
if (!character) throw notFound('CC');
|
||||
@@ -80,6 +135,8 @@ export async function createCharacter(
|
||||
},
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, playerId, 'Ein neuer CC');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.create',
|
||||
targetType: 'character',
|
||||
@@ -163,6 +220,8 @@ export async function redesignCharacter(
|
||||
},
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.redesign',
|
||||
targetType: 'character',
|
||||
@@ -267,6 +326,8 @@ export async function reactivateFree(
|
||||
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.reactivate_free',
|
||||
targetType: 'character',
|
||||
@@ -332,6 +393,8 @@ export async function reactivatePaid(
|
||||
},
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.reactivate_paid',
|
||||
targetType: 'character',
|
||||
@@ -535,6 +598,8 @@ export async function reviveDeadCharacter(
|
||||
});
|
||||
}
|
||||
|
||||
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.revive_dead',
|
||||
targetType: 'character',
|
||||
@@ -711,6 +776,8 @@ export async function addMember(
|
||||
},
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.member_added',
|
||||
targetType: 'character',
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface PackageRowParams {
|
||||
note?: string | null;
|
||||
importId?: number | null;
|
||||
actorId: number | null;
|
||||
/// Wann gekauft wurde. Ohne Angabe gilt der Zeitpunkt der Eingabe.
|
||||
occurredAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,7 @@ export function buildPackageRows(
|
||||
note: params.note ?? null,
|
||||
importId: params.importId ?? null,
|
||||
createdById: params.actorId,
|
||||
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
|
||||
};
|
||||
|
||||
if (target === 'REDESIGN_PACKAGE') {
|
||||
@@ -309,7 +312,7 @@ export async function listLedger(filter: LedgerFilter) {
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.creditLedger.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
take: filter.take ?? 100,
|
||||
skip: filter.skip ?? 0,
|
||||
include: {
|
||||
|
||||
@@ -333,6 +333,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
labels,
|
||||
playerId,
|
||||
currentBoard: board,
|
||||
postedAt: thread.postedAt,
|
||||
});
|
||||
historie.push({ threadId: thread.threadId, board });
|
||||
summary.newThreads += 1;
|
||||
@@ -345,9 +346,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
||||
const nachgetragen = existing.playerId === null && playerId !== null;
|
||||
|
||||
// Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier
|
||||
// nach, ohne dass ein vorhandener Wert überschrieben wird.
|
||||
const datumFehlt = existing.postedAt === null && thread.postedAt !== null;
|
||||
|
||||
const geaendert =
|
||||
verschoben ||
|
||||
nachgetragen ||
|
||||
datumFehlt ||
|
||||
existing.title !== thread.title ||
|
||||
existing.url !== thread.url ||
|
||||
existing.authorName !== thread.authorName ||
|
||||
@@ -364,6 +370,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
authorName: thread.authorName,
|
||||
authorForumId: thread.authorForumId,
|
||||
labels,
|
||||
...(datumFehlt ? { postedAt: thread.postedAt } : {}),
|
||||
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
|
||||
...(verschoben
|
||||
? {
|
||||
|
||||
@@ -437,6 +437,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
||||
importId: record.id,
|
||||
actorId: ctx.actorId,
|
||||
occurredAt: row.purchasedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -510,6 +511,60 @@ export async function listPendingRowsFor(playerId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Pakete insgesamt noch auf eine Buchung warten, und bei wem.
|
||||
*
|
||||
* Die Abgleiche laufen nach Zeitplan und legen Vorschauen an, ohne dass
|
||||
* jemand danach gesehen hat. Ohne diese Übersicht sammelten sie sich
|
||||
* unbemerkt an.
|
||||
*/
|
||||
export async function pendingOverview() {
|
||||
const offen = await prisma.importRow.groupBy({
|
||||
by: ['resolvedPlayerId'],
|
||||
where: {
|
||||
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||
import: { status: ImportStatus.PENDING },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
// ImportRow führt den Spieler nur als Zahl - die Zeile entsteht, bevor
|
||||
// feststeht, ob es den Spieler überhaupt gibt. Die Namen kommen deshalb
|
||||
// in einem zweiten Zug dazu.
|
||||
const ids = offen.map((g) => g.resolvedPlayerId).filter((id): id is number => id !== null);
|
||||
const namen = new Map(
|
||||
(
|
||||
await prisma.player.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, displayName: true },
|
||||
})
|
||||
).map((p) => [p.id, p.displayName]),
|
||||
);
|
||||
|
||||
const jeSpieler = new Map<number, { playerId: number; displayName: string; count: number }>();
|
||||
let ohneSpieler = 0;
|
||||
let gesamt = 0;
|
||||
|
||||
for (const gruppe of offen) {
|
||||
gesamt += gruppe._count._all;
|
||||
if (gruppe.resolvedPlayerId === null) {
|
||||
ohneSpieler += gruppe._count._all;
|
||||
continue;
|
||||
}
|
||||
jeSpieler.set(gruppe.resolvedPlayerId, {
|
||||
playerId: gruppe.resolvedPlayerId,
|
||||
displayName: namen.get(gruppe.resolvedPlayerId) ?? 'Unbekannt',
|
||||
count: gruppe._count._all,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total: gesamt,
|
||||
withoutPlayer: ohneSpieler,
|
||||
players: [...jeSpieler.values()].sort((a, b) => b.count - a.count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht einzelne Zeilen sofort, ohne auf die Bestätigung des ganzen Imports
|
||||
* zu warten. Gebuchte Zeilen werden als erledigt vermerkt - und zwar überall,
|
||||
@@ -574,6 +629,7 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
|
||||
note: row.productName ? `Import: ${row.productName}` : `Import #${row.importId}`,
|
||||
importId: row.importId,
|
||||
actorId: ctx.actorId,
|
||||
occurredAt: row.purchasedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { CreditKind } from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
import { knownSteamIds, scrapePlayers } from './scraper.js';
|
||||
import { scanForum } from './forum.js';
|
||||
import type { ActionContext } from './audit.js';
|
||||
|
||||
/**
|
||||
* Wiederkehrende Läufe.
|
||||
*
|
||||
* Beide Abgleiche greifen auf fremde Server zu, deshalb laufen sie selten und
|
||||
* gestaffelt: die Käufe mehrmals täglich, weil dort ständig etwas dazukommt;
|
||||
* das Forum einmal nachts, weil ein Antrag keine Stunde alt sein muss, um
|
||||
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
|
||||
* Seiten liest.
|
||||
*
|
||||
* Der Zeitplan steht hier, der Stand in der Datenbank. Nach einem Neustart
|
||||
* wird deshalb nicht alles erneut angestoßen.
|
||||
*/
|
||||
|
||||
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
|
||||
const TICK_MS = 5 * 60 * 1000;
|
||||
|
||||
/// Wer in letzter Zeit gekauft hat, wird häufiger geprüft.
|
||||
const AKTIVE_PRUEFUNG_MS = 2 * 60 * 60 * 1000;
|
||||
/// Wessen letzter Kauf lange zurückliegt, seltener - das spart die meisten
|
||||
/// Anfragen, denn das sind mit der Zeit die meisten Spieler.
|
||||
const RUHENDE_PRUEFUNG_MS = 6 * 60 * 60 * 1000;
|
||||
/// Ab wann jemand als ruhend gilt.
|
||||
const RUHEND_AB_MS = 3 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
|
||||
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
|
||||
|
||||
export interface RunState {
|
||||
name: string;
|
||||
lastRunAt: Date | null;
|
||||
lastStatus: string | null;
|
||||
lastNote: string | null;
|
||||
}
|
||||
|
||||
async function letzterLauf(name: string): Promise<Date | null> {
|
||||
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
|
||||
return eintrag?.lastRunAt ?? null;
|
||||
}
|
||||
|
||||
async function laufNotieren(name: string, status: string, note: string): Promise<void> {
|
||||
const daten = { lastRunAt: new Date(), lastStatus: status, lastNote: note.slice(0, 500) };
|
||||
await prisma.scheduledRun.upsert({
|
||||
where: { name },
|
||||
create: { name, ...daten },
|
||||
update: daten,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<RunState[]> {
|
||||
const eintraege = await prisma.scheduledRun.findMany({ orderBy: { name: 'asc' } });
|
||||
return eintraege.map((e) => ({
|
||||
name: e.name,
|
||||
lastRunAt: e.lastRunAt,
|
||||
lastStatus: e.lastStatus,
|
||||
lastNote: e.lastNote,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Spieler, die jetzt an der Reihe sind.
|
||||
*
|
||||
* Wer in den letzten drei Jahren etwas gekauft hat, wird alle zwei Stunden
|
||||
* geprüft. Bei allen anderen reichen sechs - wer seit Jahren nichts kauft,
|
||||
* fängt selten von einer Stunde auf die andere damit an, und es sind mit
|
||||
* Abstand die meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt
|
||||
* der Eingabe hier.
|
||||
*/
|
||||
export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
|
||||
const [spieler, letzteKaeufe] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
|
||||
select: { id: true, gextensionSyncedAt: true },
|
||||
}),
|
||||
prisma.creditLedger.groupBy({
|
||||
by: ['playerId'],
|
||||
where: { kind: CreditKind.CC, amount: { gt: 0 } },
|
||||
_max: { occurredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const kaufNach = new Map(letzteKaeufe.map((k) => [k.playerId, k._max.occurredAt]));
|
||||
|
||||
return spieler
|
||||
.filter((p) => {
|
||||
const letzterKauf = kaufNach.get(p.id) ?? null;
|
||||
const ruhend =
|
||||
letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > RUHEND_AB_MS;
|
||||
const abstand = ruhend ? RUHENDE_PRUEFUNG_MS : AKTIVE_PRUEFUNG_MS;
|
||||
|
||||
if (p.gextensionSyncedAt === null) return true;
|
||||
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
|
||||
})
|
||||
.map((p) => p.id);
|
||||
}
|
||||
|
||||
/** Ob der nächtliche Lauf für heute noch aussteht. */
|
||||
export function forumLaufFaellig(letzter: Date | null, jetzt: Date, stunde: number): boolean {
|
||||
if (jetzt.getHours() < stunde) return false;
|
||||
|
||||
const heuteUm = new Date(jetzt);
|
||||
heuteUm.setHours(stunde, 0, 0, 0);
|
||||
|
||||
return letzter === null || letzter < heuteUm;
|
||||
}
|
||||
|
||||
async function kaeufePruefen(log: Logger): Promise<void> {
|
||||
const faellig = await faelligeSpieler();
|
||||
if (faellig.length === 0) {
|
||||
log.debug('Zeitplan: keine Spieler fällig');
|
||||
return;
|
||||
}
|
||||
|
||||
const alle = await knownSteamIds();
|
||||
const ziele = alle.filter((eintrag) => faellig.includes(eintrag.playerId));
|
||||
if (ziele.length === 0) return;
|
||||
|
||||
log.info(`Zeitplan: prüfe ${ziele.length} Konten auf neue Pakete`);
|
||||
const ergebnis = await scrapePlayers(ziele, SYSTEM_CONTEXT);
|
||||
|
||||
const note =
|
||||
`${ergebnis.scannedPlayers} Konten, ${ergebnis.newRows} neue Zeilen, ` +
|
||||
`${ergebnis.unclear} unklar, ${ergebnis.failedPlayers.length} Fehler`;
|
||||
await laufNotieren('gextension', 'OK', note);
|
||||
|
||||
if (ergebnis.newRows > 0) {
|
||||
log.info(`Zeitplan: ${ergebnis.newRows} neue Pakete liegen zur Bestätigung bereit`);
|
||||
}
|
||||
}
|
||||
|
||||
async function forumPruefen(log: Logger): Promise<void> {
|
||||
log.info('Zeitplan: lese das Forum');
|
||||
const ergebnis = await scanForum(SYSTEM_CONTEXT);
|
||||
|
||||
const note =
|
||||
`${ergebnis.scannedPages} Seiten, ${ergebnis.newThreads} neu, ` +
|
||||
`${ergebnis.unassignedThreads} ohne Spieler, ${ergebnis.failedBoards.length} Board-Fehler`;
|
||||
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note);
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
|
||||
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
|
||||
*/
|
||||
export function startSchedule(log: Logger): () => void {
|
||||
if (!env.SCHEDULE_ENABLED) {
|
||||
log.info('Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false)');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let laeuft = false;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (laeuft) return;
|
||||
laeuft = true;
|
||||
|
||||
try {
|
||||
const jetzt = new Date();
|
||||
|
||||
const forumZuletzt = await letzterLauf('forum');
|
||||
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) {
|
||||
await forumPruefen(log);
|
||||
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
|
||||
}
|
||||
|
||||
const kaeufeZuletzt = await letzterLauf('gextension');
|
||||
const abstandReicht =
|
||||
kaeufeZuletzt === null || jetzt.getTime() - kaeufeZuletzt.getTime() >= AKTIVE_PRUEFUNG_MS;
|
||||
if (abstandReicht) await kaeufePruefen(log);
|
||||
} catch (error) {
|
||||
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
|
||||
await laufNotieren('letzter_fehler', 'FAILED', grund).catch(() => {});
|
||||
} finally {
|
||||
laeuft = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Der erste Takt kommt verzögert, damit der Start nicht mit einem Abgleich
|
||||
// zusammenfällt - beim Hochfahren ist genug anderes zu tun.
|
||||
const start = setTimeout(() => void tick(), 60_000);
|
||||
const timer = setInterval(() => void tick(), TICK_MS);
|
||||
start.unref();
|
||||
timer.unref();
|
||||
|
||||
log.info(
|
||||
`Zeitplan läuft: Käufe alle 2 Stunden (ruhende Konten alle 6), Forum täglich um ${env.FORUM_SCAN_HOUR} Uhr`,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(start);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user