Files
core2026/app/game-api/src/services/accountIconSync.ts
T

153 lines
5.2 KiB
TypeScript

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,
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<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,
});
};