merge: 최신 main 변경을 전투 시뮬레이터 payload 작업에 반영한다
This commit is contained in:
@@ -33,6 +33,7 @@ export interface GameApiConfig {
|
||||
gatewayInternalApiUrl: string;
|
||||
accountIconResetReconcileIntervalMs: number;
|
||||
flushChannel: string;
|
||||
webPushOutboxPollMs: number;
|
||||
}
|
||||
|
||||
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
|
||||
@@ -86,5 +87,6 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000',
|
||||
accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS),
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
webPushOutboxPollMs: parseNumberWithFallback(env.WEB_PUSH_OUTBOX_POLL_MS, 1_000, 'WEB_PUSH_OUTBOX_POLL_MS'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -88,6 +89,7 @@ export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraf
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
}
|
||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { createBestEffortResourceCloser } from './services/bestEffortResourceClo
|
||||
import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
|
||||
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -168,9 +169,23 @@ export const createGameApiServer = async () => {
|
||||
onError: (error) => app.log.error({ err: error }, 'deferred general access flush failed'),
|
||||
}
|
||||
);
|
||||
const webPushOutboxWorker = new WebPushOutboxWorker(
|
||||
postgres.prisma,
|
||||
config.gatewayInternalApiUrl,
|
||||
config.gameTokenSecret,
|
||||
config.profileName,
|
||||
{
|
||||
intervalMs: config.webPushOutboxPollMs,
|
||||
onError: (error) => app.log.error({ err: error }, 'web push outbox dispatch failed'),
|
||||
}
|
||||
);
|
||||
let flushSubscriberStarted = false;
|
||||
let realtimeHubStarted = false;
|
||||
const closeResources = createBestEffortResourceCloser([
|
||||
{
|
||||
name: 'web-push-outbox-worker',
|
||||
run: () => webPushOutboxWorker.stop(),
|
||||
},
|
||||
{
|
||||
name: 'deferred-general-access-worker',
|
||||
run: () => deferredGeneralAccessWorker.stop(),
|
||||
@@ -395,6 +410,7 @@ export const createGameApiServer = async () => {
|
||||
await flushSubscriber.start();
|
||||
flushSubscriberStarted = true;
|
||||
readModelOutboxWorker.start();
|
||||
webPushOutboxWorker.start();
|
||||
deferredGeneralAccessWorker.start();
|
||||
accountIconResetReconciler.start();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
|
||||
const MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
export interface WebPushOutboxWorkerOptions {
|
||||
intervalMs?: number;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class WebPushOutboxWorker {
|
||||
private readonly owner: string;
|
||||
private readonly intervalMs: number;
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly onError: (error: unknown) => void;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private nextPruneAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly db: GamePrismaClient,
|
||||
gatewayInternalApiUrl: string,
|
||||
secret: string,
|
||||
private readonly profileName: string,
|
||||
options: WebPushOutboxWorkerOptions = {}
|
||||
) {
|
||||
this.baseUrl = gatewayInternalApiUrl.replace(/\/$/u, '');
|
||||
this.token = deriveInternalToken(secret);
|
||||
this.owner = `game-web-push:${profileName}:${process.pid}:${randomUUID()}`;
|
||||
this.intervalMs = Math.max(250, Math.floor(options.intervalMs ?? 1_000));
|
||||
this.onError = options.onError ?? (() => undefined);
|
||||
}
|
||||
|
||||
private async dispatchBatch(): Promise<void> {
|
||||
const claimed = await this.db.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>(GamePrisma.sql`
|
||||
SELECT "id"
|
||||
FROM "web_push_outbox"
|
||||
WHERE "delivered_at" IS NULL
|
||||
AND "available_at" <= CURRENT_TIMESTAMP
|
||||
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
|
||||
ORDER BY "id"
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 50
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushOutbox.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
return tx.webPushOutbox.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
for (const event of claimed) {
|
||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const envelope: WebPushEventEnvelopeV1 = {
|
||||
version: 1,
|
||||
eventId: `game:${this.profileName}:${event.eventId}`,
|
||||
eventType: event.eventType as WebPushEventType,
|
||||
profileName: this.profileName,
|
||||
userIds: event.userIds,
|
||||
...(event.year == null ? {} : { year: event.year }),
|
||||
...(event.month == null ? {} : { month: event.month }),
|
||||
occurredAt: event.createdAt.toISOString(),
|
||||
};
|
||||
const response = await fetch(`${this.baseUrl}/internal/web-push-events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sammo-internal-token': this.token,
|
||||
},
|
||||
body: JSON.stringify(envelope),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
} catch (error) {
|
||||
const attempts = event.attempts;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: {
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||
},
|
||||
});
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "id"
|
||||
FROM "web_push_outbox"
|
||||
WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day'
|
||||
ORDER BY "id"
|
||||
LIMIT 500
|
||||
)
|
||||
DELETE FROM "web_push_outbox"
|
||||
WHERE "id" IN (SELECT "id" FROM expired)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
if (this.inFlight) return;
|
||||
this.inFlight = this.dispatchBatch()
|
||||
.catch(this.onError)
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => this.run(), this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
this.run();
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
this.run();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ const buildContext = (options: {
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined
|
||||
? worldState
|
||||
@@ -167,9 +168,11 @@ const buildContext = (options: {
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany,
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
target?.id === where.id ? target : null
|
||||
),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => {
|
||||
if (target?.id === where.id) return target;
|
||||
if (general?.id === where.id) return general;
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
@@ -193,6 +196,9 @@ const buildContext = (options: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async () => ({})),
|
||||
},
|
||||
webPushOutbox: {
|
||||
createMany: webPushOutboxCreateMany,
|
||||
},
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -225,6 +231,7 @@ const buildContext = (options: {
|
||||
findMany,
|
||||
inheritanceLogFindMany,
|
||||
messageRows,
|
||||
webPushOutboxCreateMany,
|
||||
changeJournal,
|
||||
};
|
||||
};
|
||||
@@ -613,6 +620,14 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
{ domain: 'messages.mailbox', entityId: 8 },
|
||||
|
||||
Reference in New Issue
Block a user