Zeitplan in der Oberflaeche einstellbar

Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
This commit is contained in:
2026-09-21 13:25:56 +02:00
parent db37d3388a
commit 6094e4eaef
14 changed files with 658 additions and 168 deletions
-9
View File
@@ -35,15 +35,6 @@ 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),
+26 -7
View File
@@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
// --- Audit-Log ---
/** Stand der wiederkehrenden Abgleiche. */
/** Einstellungen und 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(),
};
const { listRuns, listSettings } = await import('../services/schedule.js');
const [settings, runs] = await Promise.all([listSettings(), listRuns()]);
return { settings, runs };
});
app.patch('/api/admin/schedule/:name', async (request) => {
const { updateSetting, LAEUFE } = await import('../services/schedule.js');
const params = parseWith(
z.object({ name: z.enum(['gextension', 'forum']) }),
request.params,
);
const body = parseWith(
z.object({
enabled: z.boolean().optional(),
intervalMinutes: z.number().int().optional(),
idleIntervalMinutes: z.number().int().nullable().optional(),
idleAfterDays: z.number().int().nullable().optional(),
onlyAfterHour: z.number().int().nullable().optional(),
}),
request.body,
);
if (!LAEUFE.includes(params.name)) throw notFound('Lauf');
return updateSetting(params.name, body, contextOf(request));
});
app.get('/api/admin/audit', async (request) => {
+183 -48
View File
@@ -1,9 +1,9 @@
import { CreditKind } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest } from '../errors.js';
import { knownSteamIds, scrapePlayers } from './scraper.js';
import { scanForum } from './forum.js';
import type { ActionContext } from './audit.js';
import { recordAudit, type ActionContext } from './audit.js';
/**
* Wiederkehrende Läufe.
@@ -14,24 +14,52 @@ import type { ActionContext } from './audit.js';
* 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.
* Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt
* sich beides im Betrieb ändern, ohne den Dienst neu zu starten. Der Stand
* der Läufe steht ebenfalls dort, damit ein Neustart nicht alles erneut
* anstösst.
*/
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
const TICK_MS = 5 * 60 * 1000;
const TICK_MS = 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;
const MINUTE_MS = 60 * 1000;
const TAG_MS = 24 * 60 * MINUTE_MS;
/// Die beiden Läufe, die es gibt.
export const LAEUFE = ['gextension', 'forum'] as const;
export type LaufName = (typeof LAEUFE)[number];
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
/// Fällt ein Eintrag, greifen diese Werte - dieselben, mit denen der
/// Zeitplan ursprünglich fest verdrahtet war.
const VORGABEN: Record<LaufName, ScheduleSettingInput> = {
gextension: {
enabled: true,
intervalMinutes: 120,
idleIntervalMinutes: 360,
idleAfterDays: 1095,
onlyAfterHour: null,
},
forum: {
enabled: true,
intervalMinutes: 1440,
idleIntervalMinutes: null,
idleAfterDays: null,
onlyAfterHour: 4,
},
};
export interface ScheduleSettingInput {
enabled: boolean;
intervalMinutes: number;
idleIntervalMinutes: number | null;
idleAfterDays: number | null;
onlyAfterHour: number | null;
}
export interface RunState {
name: string;
lastRunAt: Date | null;
@@ -39,6 +67,91 @@ export interface RunState {
lastNote: string | null;
}
export async function getSetting(name: LaufName): Promise<ScheduleSettingInput> {
const eintrag = await prisma.scheduleSetting.findUnique({ where: { name } });
if (!eintrag) return VORGABEN[name];
return {
enabled: eintrag.enabled,
intervalMinutes: eintrag.intervalMinutes,
idleIntervalMinutes: eintrag.idleIntervalMinutes,
idleAfterDays: eintrag.idleAfterDays,
onlyAfterHour: eintrag.onlyAfterHour,
};
}
export async function listSettings(): Promise<(ScheduleSettingInput & { name: LaufName })[]> {
return Promise.all(
LAEUFE.map(async (name) => ({ name, ...(await getSetting(name)) })),
);
}
/**
* Ändert die Einstellungen eines Laufs.
*
* Die Grenzen sind bewusst eng: unter einer Viertelstunde wäre der Abgleich
* eine Belästigung für einen fremden Server, über einer Woche liefe er
* praktisch nicht mehr.
*/
export async function updateSetting(
name: LaufName,
input: Partial<ScheduleSettingInput>,
ctx: ActionContext,
): Promise<ScheduleSettingInput> {
const vorher = await getSetting(name);
const pruefe = (wert: number | null | undefined, feld: string): number | null | undefined => {
if (wert === undefined || wert === null) return wert;
if (!Number.isInteger(wert) || wert < 15 || wert > 10080) {
throw badRequest(
'INVALID_INTERVAL',
`${feld}: zwischen 15 Minuten und einer Woche (10080 Minuten)`,
);
}
return wert;
};
const intervalMinutes = pruefe(input.intervalMinutes, 'Abstand') ?? vorher.intervalMinutes;
const idleIntervalMinutes =
input.idleIntervalMinutes === undefined
? vorher.idleIntervalMinutes
: (pruefe(input.idleIntervalMinutes, 'Abstand für ruhende Konten') ?? null);
if (input.idleAfterDays !== undefined && input.idleAfterDays !== null) {
if (!Number.isInteger(input.idleAfterDays) || input.idleAfterDays < 1) {
throw badRequest('INVALID_DAYS', 'Ruhend ab: mindestens ein Tag');
}
}
if (input.onlyAfterHour !== undefined && input.onlyAfterHour !== null) {
if (!Number.isInteger(input.onlyAfterHour) || input.onlyAfterHour < 0 || input.onlyAfterHour > 23) {
throw badRequest('INVALID_HOUR', 'Frühestens ab: eine Stunde zwischen 0 und 23');
}
}
const daten = {
enabled: input.enabled ?? vorher.enabled,
intervalMinutes,
idleIntervalMinutes,
idleAfterDays: input.idleAfterDays === undefined ? vorher.idleAfterDays : input.idleAfterDays,
onlyAfterHour: input.onlyAfterHour === undefined ? vorher.onlyAfterHour : input.onlyAfterHour,
};
await prisma.scheduleSetting.upsert({
where: { name },
create: { name, ...daten },
update: daten,
});
await recordAudit(prisma, ctx, {
action: 'schedule.updated',
targetType: 'schedule',
targetId: null,
details: { name, ...daten },
});
return daten;
}
async function letzterLauf(name: string): Promise<Date | null> {
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
return eintrag?.lastRunAt ?? null;
@@ -66,13 +179,21 @@ export async function listRuns(): Promise<RunState[]> {
/**
* 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.
* Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen
* anderen reicht der seltenere - 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[]> {
export async function faelligeSpieler(
jetzt = new Date(),
einstellung?: ScheduleSettingInput,
): Promise<number[]> {
const regel = einstellung ?? (await getSetting('gextension'));
const aktiverTakt = regel.intervalMinutes * MINUTE_MS;
const ruhenderTakt = (regel.idleIntervalMinutes ?? regel.intervalMinutes) * MINUTE_MS;
const ruhendAb = (regel.idleAfterDays ?? 0) * TAG_MS;
const [spieler, letzteKaeufe] = await Promise.all([
prisma.player.findMany({
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
@@ -91,8 +212,8 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.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;
ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb);
const abstand = ruhend ? ruhenderTakt : aktiverTakt;
if (p.gextensionSyncedAt === null) return true;
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
@@ -100,18 +221,45 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.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;
/**
* Ob ein Lauf jetzt ansteht.
*
* Ohne Stundenfenster genügt der Abstand zum letzten Lauf. Mit Fenster wird
* zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein
* täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem
* Neustart weiter in den Tag wandern zu lassen.
*/
export function istFaellig(
letzter: Date | null,
jetzt: Date,
regel: Pick<ScheduleSettingInput, 'intervalMinutes' | 'onlyAfterHour'>,
): boolean {
if (letzter !== null) {
const vergangen = jetzt.getTime() - letzter.getTime();
if (vergangen < regel.intervalMinutes * MINUTE_MS) return false;
}
const heuteUm = new Date(jetzt);
heuteUm.setHours(stunde, 0, 0, 0);
if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
if (jetzt.getHours() < regel.onlyAfterHour) return false;
return letzter === null || letzter < heuteUm;
// Innerhalb desselben Tages nicht zweimal, wenn der Takt tageweise läuft.
if (letzter !== null && regel.intervalMinutes >= 1440) {
const heuteAb = new Date(jetzt);
heuteAb.setHours(regel.onlyAfterHour, 0, 0, 0);
if (letzter >= heuteAb) return false;
}
return true;
}
async function kaeufePruefen(log: Logger): Promise<void> {
const faellig = await faelligeSpieler();
interface Logger {
info: (msg: string) => void;
warn: (msg: string) => void;
debug: (msg: string) => void;
}
async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise<void> {
const faellig = await faelligeSpieler(new Date(), regel);
if (faellig.length === 0) {
log.debug('Zeitplan: keine Spieler fällig');
return;
@@ -144,22 +292,11 @@ async function forumPruefen(log: Logger): Promise<void> {
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> => {
@@ -169,16 +306,16 @@ export function startSchedule(log: Logger): () => void {
try {
const jetzt = new Date();
const forumZuletzt = await letzterLauf('forum');
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) {
const forumRegel = await getSetting('forum');
if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) {
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);
const kaufRegel = await getSetting('gextension');
if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) {
await kaeufePruefen(log, kaufRegel);
}
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
@@ -195,9 +332,7 @@ export function startSchedule(log: Logger): () => void {
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`,
);
log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan');
return () => {
clearTimeout(start);