feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+106
View File
@@ -0,0 +1,106 @@
import { isCanonicalIsoTimestamp, parseAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common';
import { createHmac } from 'node:crypto';
const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1';
export interface AccountIconSource {
get(userId: string): Promise<AccountIconProjection | null>;
}
export interface AccountIconResetProjection {
userId: string;
resetRevision: string;
current: AccountIconProjection;
}
export interface AccountIconResetSource {
listResets(userIds: string[]): Promise<AccountIconResetProjection[]>;
}
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const parseResetProjection = (value: unknown): AccountIconResetProjection => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('invalid account icon reset projection');
}
const record = value as Record<string, unknown>;
if (Object.keys(record).sort().join(',') !== 'current,resetRevision,userId') {
throw new Error('invalid account icon reset projection');
}
if (
typeof record.userId !== 'string' ||
typeof record.resetRevision !== 'string' ||
!isCanonicalIsoTimestamp(record.resetRevision)
) {
throw new Error('invalid account icon reset projection');
}
return {
userId: record.userId,
resetRevision: record.resetRevision,
current: parseAccountIconProjection(record.current),
};
};
export class GatewayHttpAccountIconSource implements AccountIconSource, AccountIconResetSource {
private readonly baseUrl: string;
constructor(
baseUrl: string,
private readonly secret: string,
private readonly timeoutMs = 2_000
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
async get(userId: string): Promise<AccountIconProjection | null> {
const response = await fetch(`${this.baseUrl}/internal/account-icons/${encodeURIComponent(userId)}`, {
headers: {
'x-sammo-internal-token': deriveInternalToken(this.secret),
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Gateway account icon request failed with HTTP ${response.status}.`);
}
return parseAccountIconProjection(await response.json());
}
async listResets(userIds: string[]): Promise<AccountIconResetProjection[]> {
if (userIds.length === 0) {
return [];
}
const response = await fetch(`${this.baseUrl}/internal/account-icon-resets`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-sammo-internal-token': deriveInternalToken(this.secret),
},
body: JSON.stringify({ userIds }),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
throw new Error(`Gateway account icon reset request failed with HTTP ${response.status}.`);
}
const payload = (await response.json()) as unknown;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('invalid account icon reset response');
}
const record = payload as Record<string, unknown>;
if (Object.keys(record).sort().join(',') !== 'resets' || !Array.isArray(record.resets)) {
throw new Error('invalid account icon reset response');
}
const resets = record.resets.map(parseResetProjection);
const requested = new Set(userIds);
if (
resets.some((reset) => !requested.has(reset.userId)) ||
new Set(resets.map((reset) => reset.userId)).size !== resets.length
) {
throw new Error('invalid account icon reset response');
}
return resets;
}
}
+27 -1
View File
@@ -2,6 +2,7 @@ export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
reason?: string;
iconRevision?: string;
}
export interface FlushStore {
@@ -35,6 +36,9 @@ export class RedisGatewayFlushSubscriber {
};
private readonly channel: string;
private readonly store: FlushStore;
private readonly onFlush?: (event: GatewayUserFlushEvent) => Promise<void> | void;
private readonly onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void;
private readonly pendingFlushes = new Set<Promise<void>>();
constructor(
client: {
@@ -42,11 +46,15 @@ export class RedisGatewayFlushSubscriber {
unsubscribe: (channel: string) => Promise<void>;
},
channel: string,
store: FlushStore
store: FlushStore,
onFlush?: (event: GatewayUserFlushEvent) => Promise<void> | void,
onFlushError?: (error: unknown, event: GatewayUserFlushEvent) => void
) {
this.client = client;
this.channel = channel;
this.store = store;
this.onFlush = onFlush;
this.onFlushError = onFlushError;
}
async start(): Promise<void> {
@@ -57,6 +65,23 @@ export class RedisGatewayFlushSubscriber {
return;
}
this.store.applyFlush(payload);
if (this.onFlush) {
let flush: Promise<void>;
try {
flush = Promise.resolve(this.onFlush(payload));
} catch (error) {
this.onFlushError?.(error, payload);
return;
}
const tracked = flush
.catch((error: unknown) => {
this.onFlushError?.(error, payload);
})
.finally(() => {
this.pendingFlushes.delete(tracked);
});
this.pendingFlushes.add(tracked);
}
} catch {
return;
}
@@ -65,5 +90,6 @@ export class RedisGatewayFlushSubscriber {
async stop(): Promise<void> {
await this.client.unsubscribe(this.channel);
await Promise.all(this.pendingFlushes);
}
}
+20 -16
View File
@@ -1,5 +1,13 @@
import { parseNumberWithFallback } from '@sammo-ts/common';
const parseReconcileInterval = (value: string | undefined): number => {
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
if (!Number.isSafeInteger(parsed) || parsed < 1_000) {
throw new Error('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS must be an integer of at least 1000.');
}
return parsed;
};
export interface GameApiConfig {
host: string;
port: number;
@@ -19,6 +27,8 @@ export interface GameApiConfig {
auctionTimerRetentionSeconds: number;
tournamentPollMs: number;
gameTokenSecret: string;
gatewayInternalApiUrl: string;
accountIconResetReconcileIntervalMs: number;
flushChannel: string;
}
@@ -43,7 +53,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
profile,
scenario,
profileName,
daemonRequestTimeoutMs: parseNumberWithFallback(env.DAEMON_REQUEST_TIMEOUT_MS, 5000, 'DAEMON_REQUEST_TIMEOUT_MS'),
daemonRequestTimeoutMs: parseNumberWithFallback(
env.DAEMON_REQUEST_TIMEOUT_MS,
5000,
'DAEMON_REQUEST_TIMEOUT_MS'
),
battleSimRequestTimeoutMs: parseNumberWithFallback(
env.BATTLE_SIM_REQUEST_TIMEOUT_MS,
8000,
@@ -54,27 +68,17 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
60,
'BATTLE_SIM_RESULT_TTL_SECONDS'
),
auctionTimerPollMs: parseNumberWithFallback(
env.AUCTION_TIMER_POLL_MS,
1000,
'AUCTION_TIMER_POLL_MS'
),
auctionTimerResyncMs: parseNumberWithFallback(
env.AUCTION_TIMER_RESYNC_MS,
300000,
'AUCTION_TIMER_RESYNC_MS'
),
auctionTimerPollMs: parseNumberWithFallback(env.AUCTION_TIMER_POLL_MS, 1000, 'AUCTION_TIMER_POLL_MS'),
auctionTimerResyncMs: parseNumberWithFallback(env.AUCTION_TIMER_RESYNC_MS, 300000, 'AUCTION_TIMER_RESYNC_MS'),
auctionTimerRetentionSeconds: parseNumberWithFallback(
env.AUCTION_TIMER_RETENTION_SECONDS,
21600,
'AUCTION_TIMER_RETENTION_SECONDS'
),
tournamentPollMs: parseNumberWithFallback(
env.TOURNAMENT_POLL_MS,
1000,
'TOURNAMENT_POLL_MS'
),
tournamentPollMs: parseNumberWithFallback(env.TOURNAMENT_POLL_MS, 1000, 'TOURNAMENT_POLL_MS'),
gameTokenSecret: secret,
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000',
accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS),
flushChannel: `${gatewayPrefix}:flush`,
};
};
+4
View File
@@ -7,6 +7,7 @@ import type { TurnDaemonTransport } from './daemon/transport.js';
import type { BattleSimTransport } from './battleSim/transport.js';
import type { FlushStore } from './auth/flushStore.js';
import type { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import type { AccountIconSource } from './auth/accountIconSource.js';
export interface GameProfile {
id: string;
@@ -84,6 +85,7 @@ export interface GameApiContext {
accessTokenStore: RedisAccessTokenStore;
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
}
export const createGameApiContext = (options: {
@@ -100,6 +102,7 @@ export const createGameApiContext = (options: {
accessTokenStore: RedisAccessTokenStore;
flushStore: FlushStore;
gameTokenSecret: string;
accountIconSource?: AccountIconSource;
}): GameApiContext => {
return {
requestId: options.requestId,
@@ -116,5 +119,6 @@ export const createGameApiContext = (options: {
accessTokenStore: options.accessTokenStore,
flushStore: options.flushStore,
gameTokenSecret: options.gameTokenSecret,
...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}),
};
};
+11 -12
View File
@@ -5,7 +5,8 @@ import { isAfter, isValid, parseISO } from 'date-fns';
import { z } from 'zod';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { authedProcedure, engineProcedure, router } from '../../trpc.js';
import { enqueueProfileIconResetForUser } from '../../services/accountIconSync.js';
const parseDate = (value: string): Date | null => {
const parsed = parseISO(value);
@@ -21,11 +22,7 @@ const resolveTtlSeconds = (expiresAt: string): number => {
return ttl > 0 ? ttl : 0;
};
const verifyGatewayToken = (
token: string,
profileName: string,
secret: string
): GameSessionTokenPayload | null => {
const verifyGatewayToken = (token: string, profileName: string, secret: string): GameSessionTokenPayload | null => {
const payload = decryptGameSessionToken(token, secret);
if (!payload) {
return null;
@@ -52,14 +49,10 @@ export const authRouter = router({
}
return { userId };
}),
exchangeGatewayToken: procedure
exchangeGatewayToken: engineProcedure
.input(z.object({ gatewayToken: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const payload = verifyGatewayToken(
input.gatewayToken,
ctx.profile.name,
ctx.gameTokenSecret
);
const payload = verifyGatewayToken(input.gatewayToken, ctx.profile.name, ctx.gameTokenSecret);
if (!payload) {
throw new TRPCError({
code: 'UNAUTHORIZED',
@@ -88,6 +81,12 @@ export const authRouter = router({
});
}
if (payload.user.profileIconResetAt && payload.user.profileIconResetAt === payload.user.iconUpdatedAt) {
// 일반 계정 아이콘 변경은 Ref처럼 사용자가 고른 서버에만 적용한다.
// 관리자 reset만 다음 인증 경계에서 durable하게 복구한다.
await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt);
}
const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds);
if (!used) {
throw new TRPCError({
+18 -6
View File
@@ -11,10 +11,12 @@ import {
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
authedProcedure,
engineAuthedProcedure,
router,
} from '../../trpc.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveNationNotice } from '../nation/shared.js';
@@ -169,6 +171,13 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
};
export const generalRouter = router({
adjustIcon: engineAuthedProcedure.mutation(({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return adjustAccountIconForUser(ctx, userId);
}),
me: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
@@ -326,12 +335,15 @@ export const generalRouter = router({
availableAt: result.availableAt ?? null,
};
}),
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')),
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'dieOnPrestart')
),
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'buildNationCandidate')
),
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'instantRetreat')
),
vacation: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
+9 -2
View File
@@ -14,6 +14,7 @@ import {
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import {
ConflictingTurnDaemonCommandError,
@@ -465,6 +466,7 @@ export const joinRouter = router({
});
}
const userId = auth.user.id;
const accountIcon = input.pic ? await loadAuthoritativeAccountIcon(ctx, userId) : null;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral',
@@ -479,8 +481,13 @@ export const joinRouter = router({
pic: input.pic,
character: input.character,
profileId: ctx.profile.id,
...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}),
...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}),
...(accountIcon
? {
ownerPicture: accountIcon.picture,
ownerImageServer: accountIcon.imageServer,
ownerIconRevision: accountIcon.revision,
}
: {}),
...(auth.user.canUseGeneralPicture !== undefined
? {
ownerCanUsePicture: auth.user.canUseGeneralPicture,
+105 -23
View File
@@ -22,6 +22,10 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -59,13 +63,32 @@ const resolveAuthFromToken = async (
export const createGameApiServer = async () => {
const config = resolveGameApiConfigFromEnv();
const app = fastify({
logger: true,
routerOptions: {
maxParamLength: 2048,
},
});
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await postgres.connect();
await redis.connect();
try {
await redis.connect();
} catch (error) {
await postgres.disconnect();
throw error;
}
const accountIconSource = new GatewayHttpAccountIconSource(config.gatewayInternalApiUrl, config.gameTokenSecret);
const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const accountIconResetReconciler = new AccountIconResetReconciler(
postgres.prisma,
accountIconSource,
turnDaemon,
config.accountIconResetReconcileIntervalMs,
(error) => app.log.error({ err: error }, 'account icon reset reconciliation failed')
);
const battleSim = new RedisBattleSimTransport(redis.client, {
keys: buildBattleSimQueueKeys(config.profileName),
requestTimeoutMs: config.battleSimRequestTimeoutMs,
@@ -73,21 +96,70 @@ export const createGameApiServer = async () => {
});
const flushStore = new InMemoryFlushStore();
const flushSubscriberClient = redis.client.duplicate();
await flushSubscriberClient.connect();
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
await flushSubscriber.start();
try {
await flushSubscriberClient.connect();
} catch (error) {
await redis.disconnect();
await postgres.disconnect();
throw error;
}
const flushSubscriber = new RedisGatewayFlushSubscriber(
flushSubscriberClient,
config.flushChannel,
flushStore,
createAdminProfileIconResetFlushHandler(accountIconSource, turnDaemon),
(error, event) => {
app.log.error({ err: error, userId: event.userId, reason: event.reason }, 'gateway flush handler failed');
}
);
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
const realtimeSubscriberClient = redis.client.duplicate();
await realtimeSubscriberClient.connect();
try {
await realtimeSubscriberClient.connect();
} catch (error) {
await flushSubscriberClient.quit();
await redis.disconnect();
await postgres.disconnect();
throw error;
}
const realtimeHub = new RedisRealtimeEventHub(realtimeSubscriberClient, buildGameEventChannel(config.profileName));
await realtimeHub.start();
const app = fastify({
logger: true,
routerOptions: {
maxParamLength: 2048,
let flushSubscriberStarted = false;
let realtimeHubStarted = false;
const closeResources = createBestEffortResourceCloser([
{
name: 'account-icon-reset-reconciler',
run: () => accountIconResetReconciler.stop(),
},
});
{
name: 'gateway-flush-subscriber',
run: async () => {
if (flushSubscriberStarted) await flushSubscriber.stop();
},
},
{
name: 'gateway-flush-redis-client',
run: async () => {
await flushSubscriberClient.quit();
},
},
{
name: 'realtime-redis-client',
run: async () => {
if (realtimeHubStarted) await realtimeHub.stop();
else await realtimeSubscriberClient.quit();
},
},
{
name: 'redis',
run: () => redis.disconnect(),
},
{
name: 'postgres',
run: () => postgres.disconnect(),
},
]);
app.addHook('onClose', closeResources);
await app.register(cors, {
origin: true,
@@ -127,6 +199,7 @@ export const createGameApiServer = async () => {
accessTokenStore,
flushStore,
gameTokenSecret: config.gameTokenSecret,
accountIconSource,
});
},
},
@@ -198,15 +271,19 @@ export const createGameApiServer = async () => {
app.get('/healthz', async () => ({
ok: true,
profile: config.profileName,
accountIconReconciliation: accountIconResetReconciler.getHealth(),
}));
app.addHook('onClose', async () => {
await flushSubscriber.stop();
await flushSubscriberClient.quit();
await realtimeHub.stop();
await redis.disconnect();
await postgres.disconnect();
});
try {
await realtimeHub.start();
realtimeHubStarted = true;
await flushSubscriber.start();
flushSubscriberStarted = true;
accountIconResetReconciler.start();
} catch (error) {
await closeResources();
throw error;
}
return {
app,
@@ -216,8 +293,13 @@ export const createGameApiServer = async () => {
export const runGameApiServer = async (): Promise<void> => {
const { app, config } = await createGameApiServer();
await app.listen({
host: config.host,
port: config.port,
});
try {
await app.listen({
host: config.host,
port: config.port,
});
} catch (error) {
await app.close();
throw error;
}
};
@@ -0,0 +1,238 @@
import { isCanonicalIsoTimestamp, isRecord, type AccountIconProjection } from '@sammo-ts/common';
import type { AccountIconResetProjection, AccountIconResetSource } from '../auth/accountIconSource.js';
import type { DatabaseClient } from '../context.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
const BATCH_SIZE = 500;
const MAX_TERMINAL_REQUEUES = 3;
type GeneralIconState = {
id: number;
userId: string | null;
picture: string | null;
imageServer: number;
meta: unknown;
};
export type AccountIconResetReconcilerHealth = {
running: boolean;
lastSuccessAt: string | null;
lastErrorAt: string | null;
lastError: string | null;
};
const readCurrentRevision = (meta: unknown): string | null => {
if (!isRecord(meta)) {
return null;
}
const value = meta.accountIconUpdatedAt;
return typeof value === 'string' && isCanonicalIsoTimestamp(value) ? value : null;
};
const projectionForGeneral = (general: GeneralIconState, reset: AccountIconResetProjection): AccountIconProjection => {
const currentRevision = readCurrentRevision(general.meta);
if (currentRevision) {
return {
revision: reset.resetRevision,
picture: 'default.jpg',
imageServer: 0,
};
}
// Existing installations have no per-General watermark. If the rendered
// tuple already equals a post-reset Gateway projection, seed that newer
// revision instead of replaying the historical reset over it.
if (
reset.current.revision > reset.resetRevision &&
general.picture === reset.current.picture &&
general.imageServer === reset.current.imageServer
) {
return reset.current;
}
return {
revision: reset.resetRevision,
picture: 'default.jpg',
imageServer: 0,
};
};
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const stableJson = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
.join(',')}}`;
}
return JSON.stringify(value) ?? 'null';
};
export class AccountIconResetReconciler {
private timer: NodeJS.Timeout | null = null;
private inFlight: Promise<void> | null = null;
private lastSuccessAt: string | null = null;
private lastErrorAt: string | null = null;
private lastError: string | null = null;
constructor(
private readonly db: DatabaseClient,
private readonly source: AccountIconResetSource,
private readonly turnDaemon: TurnDaemonTransport,
private readonly intervalMs: number,
private readonly onError: (error: unknown) => void = () => undefined
) {}
getHealth(): AccountIconResetReconcilerHealth {
return {
running: this.timer !== null,
lastSuccessAt: this.lastSuccessAt,
lastErrorAt: this.lastErrorAt,
lastError: this.lastError,
};
}
private async sendWithTerminalRecovery(userId: string, projection: AccountIconProjection): Promise<void> {
const baseRequestId = `general:adjustIcon:${userId}:${projection.revision}`;
const events = await this.db.inputEvent.findMany({
where: {
OR: [{ requestId: baseRequestId }, { requestId: { startsWith: `${baseRequestId}:retry:` } }],
},
select: {
requestId: true,
status: true,
eventType: true,
payload: true,
},
orderBy: { sequence: 'asc' },
});
const latest = events.at(-1);
if (latest) {
const expected = {
type: 'adjustGeneralIcon',
requestId: latest.requestId,
userId,
picture: projection.picture,
imageServer: projection.imageServer,
iconRevision: projection.revision,
};
if (latest.eventType !== 'adjustGeneralIcon' || stableJson(latest.payload) !== stableJson(expected)) {
throw new Error('account icon reset event payload conflicts with the durable journal');
}
}
if (latest?.status === 'PENDING' || latest?.status === 'PROCESSING') {
return;
}
const retryCount = events.filter(({ requestId }) => requestId !== baseRequestId).length;
if (latest && retryCount >= MAX_TERMINAL_REQUEUES) {
throw new Error(`account icon reset exhausted ${MAX_TERMINAL_REQUEUES} terminal retries`);
}
const requestId = latest ? `${baseRequestId}:retry:${retryCount + 1}` : baseRequestId;
await this.turnDaemon.sendCommand({
type: 'adjustGeneralIcon',
requestId,
userId,
picture: projection.picture,
imageServer: projection.imageServer,
iconRevision: projection.revision,
});
}
private async reconcilePage(generals: GeneralIconState[]): Promise<Error[]> {
const userIds = [...new Set(generals.flatMap((general) => (general.userId ? [general.userId] : [])))];
if (userIds.length === 0) {
return [];
}
const resets = await this.source.listResets(userIds);
const resetByUserId = new Map(resets.map((reset) => [reset.userId, reset]));
const failures: Error[] = [];
for (const general of generals) {
if (!general.userId) continue;
const reset = resetByUserId.get(general.userId);
if (!reset) continue;
const currentRevision = readCurrentRevision(general.meta);
if (currentRevision && currentRevision >= reset.resetRevision) {
continue;
}
try {
await this.sendWithTerminalRecovery(general.userId, projectionForGeneral(general, reset));
} catch (error) {
failures.push(
new Error(`account icon reset failed for user ${general.userId}: ${errorMessage(error)}`, {
cause: error,
})
);
}
}
return failures;
}
async reconcileOnce(): Promise<void> {
const failures: Error[] = [];
let cursorId: number | null = null;
try {
while (true) {
const generals: GeneralIconState[] = await this.db.general.findMany({
where: {
userId: { not: null },
npcState: 0,
},
select: {
id: true,
userId: true,
picture: true,
imageServer: true,
meta: true,
},
orderBy: { id: 'asc' },
take: BATCH_SIZE,
...(cursorId === null ? {} : { cursor: { id: cursorId }, skip: 1 }),
});
failures.push(...(await this.reconcilePage(generals)));
if (generals.length < BATCH_SIZE) break;
cursorId = generals.at(-1)?.id ?? null;
if (cursorId === null) break;
}
if (failures.length > 0) {
throw new AggregateError(failures, `${failures.length} account icon reset reconciliation(s) failed`);
}
this.lastSuccessAt = new Date().toISOString();
this.lastErrorAt = null;
this.lastError = null;
} catch (error) {
this.lastErrorAt = new Date().toISOString();
this.lastError = errorMessage(error);
throw error;
}
}
start(): void {
if (this.timer) {
return;
}
const run = (): void => {
if (this.inFlight) {
return;
}
this.inFlight = this.reconcileOnce()
.catch((error: unknown) => this.onError(error))
.finally(() => {
this.inFlight = null;
});
};
run();
this.timer = setInterval(run, this.intervalMs);
this.timer.unref?.();
}
async stop(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
await this.inFlight;
}
}
@@ -0,0 +1,146 @@
import { TRPCError } from '@trpc/server';
import { isCanonicalIsoTimestamp, type AccountIconProjection } from '@sammo-ts/common';
import type { GameApiContext } from '../context.js';
import { ConflictingTurnDaemonCommandError } from '../daemon/databaseTransport.js';
import type { AccountIconSource } from '../auth/accountIconSource.js';
import type { GatewayUserFlushEvent } from '../auth/flushStore.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
export const loadAuthoritativeAccountIcon = async (
ctx: GameApiContext,
userId: string
): Promise<AccountIconProjection> => {
if (!ctx.accountIconSource) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Gateway 계정 아이콘 원장이 구성되지 않았습니다.',
});
}
try {
const projection = await ctx.accountIconSource.get(userId);
if (!projection) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Gateway에서 계정 정보를 찾을 수 없습니다.',
});
}
return projection;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Gateway 계정 아이콘 정보를 확인할 수 없습니다.',
});
}
};
export const adjustAccountIconForUser = async (
ctx: GameApiContext,
userId: string
): Promise<{
ok: true;
generalId: number | null;
updated: boolean;
}> => {
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
const requestId = `general:adjustIcon:${userId}:${projection.revision}`;
try {
const result = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralIcon',
requestId,
userId,
picture: projection.picture,
imageServer: projection.imageServer,
iconRevision: projection.revision,
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== 'adjustGeneralIcon') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 아이콘 적용 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({
code: result.code,
message: result.reason,
});
}
return {
ok: true,
generalId: result.generalId,
updated: result.updated,
};
} catch (error) {
if (
error instanceof ConflictingTurnDaemonCommandError ||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 접수된 아이콘 적용 요청과 최신 계정 정보가 다릅니다.',
});
}
throw error;
}
};
export const enqueueProfileIconResetForUser = async (
ctx: GameApiContext,
userId: string,
expectedResetRevision: string
): Promise<boolean> => {
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
if (
projection.revision !== expectedResetRevision ||
projection.picture !== 'default.jpg' ||
projection.imageServer !== 0
) {
return false;
}
await ctx.turnDaemon.sendCommand({
type: 'adjustGeneralIcon',
requestId: `general:adjustIcon:${userId}:${projection.revision}`,
userId,
picture: projection.picture,
imageServer: projection.imageServer,
iconRevision: projection.revision,
});
return true;
};
export const createAdminProfileIconResetFlushHandler =
(source: AccountIconSource, turnDaemon: TurnDaemonTransport) =>
async (event: GatewayUserFlushEvent): Promise<void> => {
if (event.reason !== 'admin-profile-icon-reset') {
return;
}
if (!event.iconRevision || !isCanonicalIsoTimestamp(event.iconRevision)) {
return;
}
const projection = await source.get(event.userId);
if (
!projection ||
projection.revision !== event.iconRevision ||
projection.picture !== 'default.jpg' ||
projection.imageServer !== 0
) {
return;
}
await turnDaemon.sendCommand({
type: 'adjustGeneralIcon',
requestId: `general:adjustIcon:${event.userId}:${projection.revision}`,
userId: event.userId,
picture: projection.picture,
imageServer: projection.imageServer,
iconRevision: projection.revision,
});
};
@@ -0,0 +1,40 @@
export interface ResourceCleanupStep {
name: string;
run: () => Promise<void>;
}
/**
* 종료 단계 하나가 실패해도 나머지 연결을 모두 닫고, 다음 호출에서는 실패한
* 단계만 재시도합니다. 동시에 들어온 종료 요청은 같은 실행을 공유합니다.
*/
export const createBestEffortResourceCloser = (steps: readonly ResourceCleanupStep[]): (() => Promise<void>) => {
const completed = new Set<string>();
let closing: Promise<void> | null = null;
return async (): Promise<void> => {
if (completed.size === steps.length) return;
if (closing) return closing;
closing = (async () => {
const errors: Error[] = [];
for (const step of steps) {
if (completed.has(step.name)) continue;
try {
await step.run();
completed.add(step.name);
} catch (error) {
errors.push(new Error(`Failed to close resource: ${step.name}`, { cause: error }));
}
}
if (errors.length > 0) {
throw new AggregateError(errors, 'One or more resources failed to close.');
}
})();
try {
await closing;
} finally {
closing = null;
}
};
};
+1
View File
@@ -107,6 +107,7 @@ export const accessAuthedProcedure: typeof procedure = t.procedure
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const engineProcedure: typeof procedure = t.procedure;
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);