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 => { 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, selected?: AccountIconProjection, enforceCooldown = true, requestKey?: string ): Promise<{ ok: true; generalId: number | null; updated: boolean; }> => { const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId)); const requestId = selected ? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}` : `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, enforceCooldown, }); 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 => { 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 => { 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, }); };