diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index dbd481d..c66911b 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -174,13 +174,33 @@ const resolvePenalty = (penalty: unknown): Record => { }; 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); - }), + adjustIcon: engineAuthedProcedure + .input( + z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional() + ) + .mutation(({ ctx, input }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined; + if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) { + throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); + } + return adjustAccountIconForUser( + ctx, + userId, + selected + ? { + picture: selected.picture, + imageServer: selected.imageServer, + revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt, + } + : undefined, + true, + input?.clientRequestId ?? ctx.requestId + ); + }), me: authedProcedure.query(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { @@ -296,6 +316,12 @@ export const generalRouter = router({ item: normalizeItemCode(general.itemCode), }, }, + iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), + canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, + iconChangeAvailableAt: + typeof metaRecord.generalIconChangedAt === 'string' + ? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString() + : null, city, nation, settings, diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 79d9bad..86e4287 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -329,6 +329,8 @@ export const joinRouter = router({ id: ctx.auth?.user.id ?? '', displayName: ctx.auth?.user.displayName ?? '', canCreateGeneral: ctx.auth?.identity?.canCreateGeneral !== false, + icons: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), + preferredPicture: ctx.auth?.user.picture ?? 'default.jpg', }, personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities], warSpecials, @@ -383,6 +385,7 @@ export const joinRouter = router({ z.object({ uniqueName: z.string().min(1).max(20), personality: z.string().min(1), + iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional(), }) ) @@ -392,6 +395,10 @@ export const joinRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } const userId = auth.user.id; + const selectedIcon = input.iconId ? auth.user.icons?.find((icon) => icon.id === input.iconId) : undefined; + if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) { + throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); + } if (auth.identity?.canCreateGeneral === false) { throw new TRPCError({ code: 'FORBIDDEN', @@ -407,6 +414,13 @@ export const joinRouter = router({ uniqueName: input.uniqueName, personality: input.personality, seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, + ...(selectedIcon + ? { + ownerPicture: selectedIcon.picture, + ownerImageServer: selectedIcon.imageServer, + ownerIconRevision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt, + } + : {}), }); return resolveSelectionCommandResult(result, 'selectPoolCreate'); }), @@ -446,6 +460,7 @@ export const joinRouter = router({ strength: z.number().int(), intel: z.number().int(), pic: z.boolean(), + iconId: z.string().uuid().optional(), character: zJoinPersonality, clientRequestId: z.string().uuid().optional(), inheritSpecial: z.string().optional(), @@ -466,7 +481,19 @@ export const joinRouter = router({ }); } const userId = auth.user.id; - const accountIcon = input.pic ? await loadAuthoritativeAccountIcon(ctx, userId) : null; + const selectedIcon = input.iconId ? auth.user.icons?.find((icon) => icon.id === input.iconId) : undefined; + if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) { + throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); + } + const accountIcon = input.pic + ? selectedIcon + ? { + picture: selectedIcon.picture, + imageServer: selectedIcon.imageServer, + revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt, + } + : await loadAuthoritativeAccountIcon(ctx, userId) + : null; const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId); const result = await requestJoinCreateCommand(ctx, { type: 'joinCreateGeneral', diff --git a/app/game-api/src/services/accountIconSync.ts b/app/game-api/src/services/accountIconSync.ts index 3064af3..ed6b724 100644 --- a/app/game-api/src/services/accountIconSync.ts +++ b/app/game-api/src/services/accountIconSync.ts @@ -39,14 +39,19 @@ export const loadAuthoritativeAccountIcon = async ( export const adjustAccountIconForUser = async ( ctx: GameApiContext, - userId: string + userId: string, + selected?: AccountIconProjection, + enforceCooldown = true, + requestKey?: string ): Promise<{ ok: true; generalId: number | null; updated: boolean; }> => { - const projection = await loadAuthoritativeAccountIcon(ctx, userId); - const requestId = `general:adjustIcon:${userId}:${projection.revision}`; + 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', @@ -55,6 +60,7 @@ export const adjustAccountIconForUser = async ( picture: projection.picture, imageServer: projection.imageServer, iconRevision: projection.revision, + enforceCooldown, }); if (!result) { throw new TRPCError({ diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index bb58f50..e0b80bc 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -423,6 +423,38 @@ describe('appRouter', () => { picture: 'latest.png', imageServer: 1, iconRevision: currentAccountIcon.revision, + enforceCooldown: true, + }); + }); + + it('uses a fresh client request id when selecting a registered in-game icon', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const clientRequestId = 'b24454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; + const requestId = `general:adjustIcon:user-1:manual:${clientRequestId}`; + transport.setCommandResult(requestId, { + type: 'adjustGeneralIcon', + ok: true, + generalId: 1, + updated: true, + }); + const auth = buildAuth(); + auth.user.icons = [ + { + id: '3f804277-584f-4f44-b39c-9ecf40d1ed31', + picture: 'manual.png', + imageServer: 1, + createdAt: '2026-07-30T09:00:00.000Z', + }, + ]; + const caller = appRouter.createCaller(buildContext({ auth, transport })); + + await caller.general.adjustIcon({ iconId: auth.user.icons[0]!.id, clientRequestId }); + + expect(transport.commands.at(-1)?.command).toMatchObject({ + requestId, + picture: 'manual.png', + imageServer: 1, + enforceCooldown: true, }); }); @@ -590,6 +622,74 @@ describe('appRouter', () => { }); }); + it('creates a general with the selected authenticated icon and rejects another icon id', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const clientRequestId = '924454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; + transport.setCommandResult(`join-create:user-1:${clientRequestId}`, { + type: 'joinCreateGeneral', + ok: true, + generalId: 43, + }); + const auth = buildAuth(); + auth.user.iconUpdatedAt = '2026-08-01T09:00:00.000Z'; + auth.user.icons = [ + { + id: '3f804277-584f-4f44-b39c-9ecf40d1ed31', + picture: 'selected.png', + imageServer: 1, + createdAt: '2026-07-30T09:00:00.000Z', + }, + ]; + const caller = appRouter.createCaller(buildContext({ state: buildWorldState(), auth, transport })); + + await caller.join.createGeneral({ + name: '선택전콘', + leadership: 55, + strength: 55, + intel: 55, + pic: true, + iconId: auth.user.icons[0]!.id, + character: 'Random', + clientRequestId, + }); + expect(transport.commands.at(-1)?.command).toMatchObject({ + ownerPicture: 'selected.png', + ownerImageServer: 1, + ownerIconRevision: auth.user.iconUpdatedAt, + }); + + const poolRequestId = 'a24454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; + transport.setCommandResult(`select-pool:user-1:${poolRequestId}:create`, { + type: 'selectPoolCreate', + ok: true, + generalId: 44, + }); + await caller.join.selectPoolGeneral({ + uniqueName: '선택풀장수', + personality: 'Random', + iconId: auth.user.icons[0]!.id, + clientRequestId: poolRequestId, + }); + expect(transport.commands.at(-1)?.command).toMatchObject({ + type: 'selectPoolCreate', + ownerPicture: 'selected.png', + ownerImageServer: 1, + ownerIconRevision: auth.user.iconUpdatedAt, + }); + + await expect( + caller.join.createGeneral({ + name: '타인전콘', + leadership: 55, + strength: 55, + intel: 55, + pic: true, + iconId: 'f6af46a2-809a-481d-b66d-0f7bbb706780', + character: 'Random', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('queues turn daemon run commands', async () => { const transport = new InMemoryTurnDaemonTransport(); const caller = appRouter.createCaller(buildContext({ transport })); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 086bda6..e76771e 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -261,6 +261,7 @@ const zAdjustGeneralIcon = z picture: z.string().min(1), imageServer: z.number().int().nonnegative(), iconRevision: z.string().refine(isCanonicalIsoTimestamp), + enforceCooldown: z.boolean().optional(), }) .strict(); @@ -312,6 +313,9 @@ const zSelectPoolCreate = z uniqueName: z.string().min(1).max(20), personality: z.string().min(1), seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), + ownerPicture: z.string().optional(), + ownerImageServer: z.number().int().nonnegative().optional(), + ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(), }) .strict(); diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index bf30fc1..da8cf26 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -515,6 +515,9 @@ export const createGeneralFromSelectionPool = async (options: { personality: string; now?: Date; seedOwnerIdentity?: string | number; + ownerPicture?: string; + ownerImageServer?: number; + ownerIconRevision?: string; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; requirePoolWorld(worldState); @@ -555,7 +558,10 @@ export const createGeneralFromSelectionPool = async (options: { ); const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config); const showImgLevel = asNumber(config.showImgLevel, 0); - const picture = showImgLevel >= 3 ? info.picture : 'default.jpg'; + const useOwnerPicture = + showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; + const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg'; + const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr; const defaultSpecialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality); @@ -576,7 +582,7 @@ export const createGeneralFromSelectionPool = async (options: { bornYear: worldState.currentYear - age, deadYear: worldState.currentYear + 60, picture, - imageServer: info.imgsvr, + imageServer, stats: { leadership: info.leadership, strength: info.strength, @@ -630,6 +636,9 @@ export const createGeneralFromSelectionPool = async (options: { next_change: nextChangeAt.toISOString(), nextChangeAt: nextChangeAt.toISOString(), prestart_delete_after: prestartDeleteAfter.toISOString(), + ...(useOwnerPicture && options.ownerIconRevision + ? { accountIconUpdatedAt: options.ownerIconRevision } + : {}), npc_org: 0, }, }; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index f0ffcd6..9124f81 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -326,6 +326,9 @@ async function handleSelectPoolCreate( uniqueName: command.uniqueName, personality: command.personality, seedOwnerIdentity: command.seedOwnerIdentity, + ...(command.ownerPicture ? { ownerPicture: command.ownerPicture } : {}), + ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), + ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), now: acceptedAt, })), }; @@ -711,7 +714,7 @@ async function handleAdjustGeneralIcon( command: Extract ): Promise { const db = requireCommandDatabase(ctx); - await resolveCommandAcceptedAt(db, command); + const acceptedAt = await resolveCommandAcceptedAt(db, command); const general = ctx.world .listGenerals() .find((candidate) => candidate.userId === command.userId && candidate.npcState === 0); @@ -724,6 +727,44 @@ async function handleAdjustGeneralIcon( }; } + if (command.enforceCooldown) { + if (general.picture === command.picture && general.imageServer === command.imageServer) { + return { + type: 'adjustGeneralIcon', + ok: true, + generalId: general.id, + updated: false, + }; + } + const changedAt = general.meta.generalIconChangedAt; + if (typeof changedAt === 'string' && isCanonicalIsoTimestamp(changedAt)) { + const availableAt = new Date(new Date(changedAt).getTime() + 24 * 60 * 60 * 1000); + if (availableAt.getTime() > acceptedAt.getTime()) { + return { + type: 'adjustGeneralIcon', + ok: false, + code: 'TOO_MANY_REQUESTS', + reason: `${availableAt.toISOString()}부터 전용 아이콘을 다시 바꿀 수 있습니다.`, + availableAt: availableAt.toISOString(), + }; + } + } + ctx.world.updateGeneral(general.id, { + picture: command.picture, + imageServer: command.imageServer, + meta: { + ...general.meta, + generalIconChangedAt: acceptedAt.toISOString(), + }, + }); + return { + type: 'adjustGeneralIcon', + ok: true, + generalId: general.id, + updated: true, + }; + } + const currentRevision = general.meta.accountIconUpdatedAt; if (currentRevision !== undefined) { if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) { diff --git a/app/game-engine/test/accountIconCommand.test.ts b/app/game-engine/test/accountIconCommand.test.ts index 7c63935..5afc50d 100644 --- a/app/game-engine/test/accountIconCommand.test.ts +++ b/app/game-engine/test/accountIconCommand.test.ts @@ -88,11 +88,11 @@ const buildWorld = (generals: TurnGeneral[]) => { return new InMemoryTurnWorld(state, snapshot, { schedule }); }; -const buildCommandDb = (actorUserId = 'user-1') => +const buildCommandDb = (actorUserId = 'user-1', acceptedAt = new Date('2026-07-31T09:00:00.000Z')) => ({ inputEvent: { findUnique: vi.fn(async () => ({ - createdAt: new Date('2026-07-31T09:00:00.000Z'), + createdAt: acceptedAt, actorUserId, target: 'ENGINE', eventType: 'adjustGeneralIcon', @@ -107,6 +107,7 @@ const command = ( picture: string; imageServer: number; iconRevision: string; + enforceCooldown: boolean; }> = {} ) => ({ type: 'adjustGeneralIcon' as const, @@ -196,4 +197,26 @@ describe('adjustGeneralIcon ENGINE command', () => { ); expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 }); }); + + it('allows human in-game changes only after a rolling 24-hour window', async () => { + const changedAt = '2026-07-31T08:00:00.000Z'; + const world = buildWorld([buildGeneral({ meta: { killturn: 24, generalIconChangedAt: changedAt } })]); + const handler = createTurnDaemonCommandHandler({ world }); + const manual = command({ enforceCooldown: true }); + + await expect(handler.handle(manual, { db: buildCommandDb() })).resolves.toMatchObject({ + ok: false, + code: 'TOO_MANY_REQUESTS', + availableAt: '2026-08-01T08:00:00.000Z', + }); + expect(world.getGeneralById(1)).toMatchObject({ picture: 'old.jpg' }); + + await expect( + handler.handle(manual, { db: buildCommandDb('user-1', new Date('2026-08-01T08:00:00.000Z')) }) + ).resolves.toMatchObject({ ok: true, updated: true }); + expect(world.getGeneralById(1)).toMatchObject({ + picture: 'new.png', + meta: { generalIconChangedAt: '2026-08-01T08:00:00.000Z' }, + }); + }); }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index fce3e42..acca5da 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -40,6 +40,10 @@ type FixtureState = { nationNoticeInput?: string; settingMutations: Array>; accessPages: string[]; + iconChoices?: Array<{ id: string; picture: string; imageServer: number; createdAt: string }>; + adjustIconInputs?: Array>; + joinConfig?: Record; + createGeneralInputs?: Array>; }; type TrpcRequestPayload = { @@ -79,6 +83,9 @@ const myGeneral = (state: FixtureState) => ({ myset: state.myset, }, penalties: {}, + iconChoices: state.iconChoices ?? [], + canChangeIcon: true, + iconChangeAvailableAt: null, }); const battleCenter = (state: FixtureState) => ({ @@ -168,8 +175,13 @@ const install = async (page: Page, state: FixtureState) => { const jsonInput = payload?.json ?? payload?.input?.json ?? (payload as Record | undefined) ?? {}; if (operation === 'auth.status') return response({ ok: true }); - if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); - if (operation === 'join.getConfig') return response({}); + if (operation === 'lobby.info') + return response({ myGeneral: state.joinConfig ? null : { id: 7, name: '검증장수' } }); + if (operation === 'join.getConfig') return response(state.joinConfig ?? {}); + if (operation === 'join.createGeneral') { + state.createGeneralInputs?.push(jsonInput); + return response({ generalId: 9 }); + } if (operation === 'general.me') { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; return response(myGeneral(state)); @@ -307,6 +319,10 @@ const install = async (page: Page, state: FixtureState) => { state.myset = Math.max(0, state.myset - 1); return response({ ok: true }); } + if (operation === 'general.adjustIcon') { + state.adjustIconInputs?.push(jsonInput); + return response({ ok: true, generalId: 7, updated: true }); + } if (operation === 'public.recordAccess') { const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null; if (pageName) state.accessPages.push(pageName); @@ -552,6 +568,87 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac await persistParityArtifact(page, 'core-my-page-mobile', mobile); }); +test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => { + const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31'; + const state: FixtureState = { + permission: 'member', + myset: 1, + settingMutations: [], + accessPages: [], + iconChoices: [ + { + id: iconId, + picture: 'mine.png', + imageServer: 1, + createdAt: '2026-08-01T00:00:00.000Z', + }, + ], + adjustIconInputs: [], + }; + await install(page, state); + await page.goto('my-page'); + await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible(); + await page.locator('.general-icon-choice input').check(); + page.once('dialog', async (dialog) => dialog.accept()); + await page.getByRole('button', { name: '아이콘 변경' }).click(); + await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1); + expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId }); + expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); +}); + +test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다', async ({ page }) => { + const firstIconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31'; + const secondIconId = 'f6af46a2-809a-481d-b66d-0f7bbb706780'; + const state: FixtureState = { + permission: 'member', + myset: 1, + settingMutations: [], + accessPages: [], + createGeneralInputs: [], + joinConfig: { + rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true }, + user: { + id: 'user-1', + displayName: '생성장수', + canCreateGeneral: true, + preferredPicture: 'first.png', + icons: [ + { + id: firstIconId, + picture: 'first.png', + imageServer: 1, + createdAt: '2026-07-31T00:00:00.000Z', + }, + { + id: secondIconId, + picture: 'second.png', + imageServer: 1, + createdAt: '2026-08-01T00:00:00.000Z', + }, + ], + }, + personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }], + nations: [], + selectionPool: { enabled: false }, + npcPossession: { enabled: false }, + inherit: null, + }, + }; + await install(page, state); + await page.goto('join'); + + const choices = page.getByRole('radiogroup', { name: '전용 아이콘 선택' }).getByRole('radio'); + await expect(choices).toHaveCount(2); + await expect(choices.nth(0)).toBeChecked(); + await choices.nth(1).check(); + await page.getByRole('button', { name: '장수 생성', exact: true }).last().click(); + + await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1); + expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId }); +}); + test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => { const state: FixtureState = { permission: 'head', diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 6658ffc..38010c3 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -43,6 +43,7 @@ const error = ref(null); const submitting = ref(false); const joinConfig = ref(null); +const accountIcons = computed(() => joinConfig.value?.user.icons ?? []); const activeTab = ref<'create' | 'possess'>('create'); const pendingJoinStorageKey = 'sammo-join-create-pending-action'; const pendingPossessStorageKey = 'sammo-npc-possess-pending-action'; @@ -54,6 +55,7 @@ const form = ref({ intel: 0, character: 'Random', pic: true, + iconId: undefined, inheritBonusStat: [0, 0, 0], }); @@ -401,6 +403,7 @@ const loadConfig = async () => { form.value = pending.input; } else { form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위'; + form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id; applyBalancedStats(); } } catch (err) { @@ -644,6 +647,23 @@ onUnmounted(() => { +
+
전용 아이콘 선택
+ +
+ +
+
+
@@ -1381,4 +1401,17 @@ onUnmounted(() => { .ghost { background: transparent; } + +.icon-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + +.icon-card { + display: flex; + align-items: center; + gap: 3px; +} diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index a9b84e9..8b78c34 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -40,6 +40,7 @@ const loading = ref(false); const error = ref(null); const screenMode = ref('auto'); const customCss = ref(''); +const selectedIconId = ref(''); const cssSaving = ref(false); const session = useSessionStore(); let cssTimer: number | null = null; @@ -132,6 +133,7 @@ const items = computed data.value?.iconChoices ?? []); const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief)); @@ -205,6 +207,10 @@ const loadPage = async (resetImmediateActionIds = true) => { dieOnPrestartStatus.value = prestartStatus; if (general) { Object.assign(form, general.settings); + selectedIconId.value = + iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ?? + iconChoices.value[0]?.id ?? + ''; } await Promise.all(logTypes.map((type) => loadLog(type))); if (resetImmediateActionIds) { @@ -217,6 +223,20 @@ const loadPage = async (resetImmediateActionIds = true) => { } }; +const changeGeneralIcon = async () => { + if (!selectedIconId.value) return; + if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return; + try { + await trpc.general.adjustIcon.mutate({ + iconId: selectedIconId.value, + clientRequestId: crypto.randomUUID(), + }); + await loadPage(); + } catch (cause) { + alert(`실패했습니다: ${errorText(cause)}`); + } +}; + const saveSettings = async () => { if (!canSave.value) return; try { @@ -427,6 +447,25 @@ onMounted(() => { 휴가 신청
+
+ 전용 아이콘 변경 (24시간에 1회)
+ + 다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }} + +
+ +
+ +
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)
@@ -759,6 +798,18 @@ dt { min-height: 32px; margin-top: 8px; } +.general-icon-list { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + margin: 6px 0; +} +.general-icon-choice { + display: flex; + align-items: center; + gap: 2px; +} @media (max-width: 991px) { .legacy-page { width: 500px; diff --git a/app/game-frontend/src/views/SelectGeneralView.vue b/app/game-frontend/src/views/SelectGeneralView.vue index e4c6f4b..4727afe 100644 --- a/app/game-frontend/src/views/SelectGeneralView.vue +++ b/app/game-frontend/src/views/SelectGeneralView.vue @@ -15,6 +15,7 @@ type PendingSelectionAction = { operation: 'create' | 'reselect'; uniqueName: string; personality?: string; + iconId?: string; clientRequestId: string; }; @@ -26,6 +27,7 @@ const reservation = ref(null); const selectedUniqueName = ref(null); const nations = ref([]); const personality = ref('Random'); +const selectedIconId = ref(''); const loading = ref(true); const submitting = ref(false); const error = ref(''); @@ -66,7 +68,8 @@ const readPendingAction = (): PendingSelectionAction | null => { if ( (value.operation !== 'create' && value.operation !== 'reselect') || typeof value.uniqueName !== 'string' || - typeof value.clientRequestId !== 'string' + typeof value.clientRequestId !== 'string' || + (value.iconId !== undefined && typeof value.iconId !== 'string') ) { return null; } @@ -79,13 +82,15 @@ const readPendingAction = (): PendingSelectionAction | null => { const getPendingAction = ( operation: PendingSelectionAction['operation'], uniqueName: string, - requestedPersonality?: string + requestedPersonality?: string, + requestedIconId?: string ): PendingSelectionAction => { const current = readPendingAction(); if ( current?.operation === operation && current.uniqueName === uniqueName && - current.personality === requestedPersonality + current.personality === requestedPersonality && + current.iconId === requestedIconId ) { return current; } @@ -93,6 +98,7 @@ const getPendingAction = ( operation, uniqueName, ...(requestedPersonality ? { personality: requestedPersonality } : {}), + ...(requestedIconId ? { iconId: requestedIconId } : {}), clientRequestId: crypto.randomUUID(), }; window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next)); @@ -200,11 +206,12 @@ const createGeneral = async (): Promise => { return; } submitting.value = true; - const pending = getPendingAction('create', candidate.uniqueName, personality.value); + const pending = getPendingAction('create', candidate.uniqueName, personality.value, selectedIconId.value); try { await trpc.join.selectPoolGeneral.mutate({ uniqueName: candidate.uniqueName, personality: personality.value, + ...(selectedIconId.value ? { iconId: selectedIconId.value } : {}), clientRequestId: pending.clientRequestId, }); clearPendingAction(pending); @@ -432,6 +439,24 @@ onBeforeUnmount(() => { + + 전콘 선택 + + + + + 임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다. diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index b424528..c45c654 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -14,6 +14,9 @@ import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js'; const zSessionToken = z.string().min(1); const MAX_ICON_BYTES = 50 * 1024; +const MAX_ACTIVE_ICONS = 5; +const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']); const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise => { @@ -46,8 +49,12 @@ export const kstDayStart = (value: Date): Date => { }; const assertIconChangeAvailable = (user: UserRecord, now: Date): void => { - if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) { - throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' }); + if ( + user.picture !== 'default.jpg' && + user.iconUpdatedAt && + new Date(user.iconUpdatedAt).getTime() > now.getTime() - ICON_UPLOAD_COOLDOWN_MS + ) { + throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘 업로드는 24시간에 한 번만 가능합니다.' }); } }; @@ -67,6 +74,18 @@ const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null = return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`; }; +const buildLibraryIcon = ( + ctx: GatewayApiContext, + icon: Awaited>[number] +) => ({ + id: icon.id, + picture: icon.picture, + imageServer: icon.imageServer, + createdAt: icon.createdAt, + retiredAt: icon.retiredAt ?? null, + url: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`, +}); + const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) => (await ctx.profileStatus.listLobbyProfiles({ userId })) .filter( @@ -95,6 +114,7 @@ const publishIconFlush = async ( export const accountRouter = router({ get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); + const icons = await ctx.users.listIcons(user.id); return { id: user.id, username: user.username, @@ -103,6 +123,15 @@ export const accountRouter = router({ oauthType: user.oauthType, createdAt: user.createdAt, iconUrl: buildIconUrl(ctx, user), + icons: icons.map((icon) => buildLibraryIcon(ctx, icon)), + preferredPicture: resolveEffectiveAccountIcon(user).picture, + maxActiveIcons: MAX_ACTIVE_ICONS, + nextUploadAt: user.iconUpdatedAt + ? new Date(new Date(user.iconUpdatedAt).getTime() + ICON_UPLOAD_COOLDOWN_MS).toISOString() + : null, + nextRetireAt: user.iconRetiredAt + ? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString() + : null, thirdPartyUse: user.thirdPartyUse, deleteAfter: user.deleteAfter ?? null, }; @@ -184,39 +213,104 @@ export const accountRouter = router({ const filename = `${randomBytes(8).toString('hex')}.${extension}`; await fs.mkdir(ctx.userIconDir, { recursive: true }); await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' }); - let revision: string | null; + let stored; try { - revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true); + stored = await ctx.users.addIconForWindow( + user.id, + filename, + 1, + now, + new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS), + MAX_ACTIVE_ICONS + ); } catch (error) { await fs.rm(path.join(ctx.userIconDir, filename), { force: true }); throw error; } - if (!revision) { + if (!stored.ok) { await fs.rm(path.join(ctx.userIconDir, filename), { force: true }); + if (stored.reason === 'LIMIT') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.', + }); + } throw new TRPCError({ code: 'TOO_MANY_REQUESTS', - message: '아이콘은 하루에 한 번만 변경할 수 있습니다.', + message: '아이콘 업로드는 24시간에 한 번만 가능합니다.', }); } const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed'); return { ok: true, iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`, - revision, + revision: stored.revision, + icon: buildLibraryIcon(ctx, stored.icon), profiles, flushPublished, }; }), + setPreferredIcon: procedure + .input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + const revision = await ctx.users.setPreferredIcon(user.id, input.iconId, new Date()); + if (!revision) { + throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); + } + const updated = await ctx.users.findById(user.id); + if (!updated) throw new TRPCError({ code: 'NOT_FOUND' }); + const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed'); + return { ok: true, revision, iconUrl: buildIconUrl(ctx, updated), flushPublished }; + }), + retireIcon: procedure + .input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const user = await requireSessionUser(ctx, input.sessionToken); + const now = new Date(); + const result = await ctx.users.retireIconForWindow( + user.id, + input.iconId, + now, + new Date(now.getTime() - ICON_RETIRE_COOLDOWN_MS) + ); + if (!result.ok) { + if (result.reason === 'COOLDOWN') { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: '전용 아이콘은 7일에 한 번만 목록에서 내릴 수 있습니다.', + }); + } + throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); + } + const updated = await ctx.users.findById(user.id); + const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed'); + return { + ok: true, + revision: result.revision, + preferredChanged: result.preferredChanged, + iconUrl: updated ? buildIconUrl(ctx, updated) : null, + flushPublished, + }; + }), deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const now = new Date(); assertIconChangeAvailable(user, now); const profiles = await listIconSyncProfiles(ctx, user.id); - const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false); + const revision = await ctx.users.updateIconForDay( + user.id, + 'default.jpg', + 0, + now, + new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS), + false, + true + ); if (!revision) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', - message: '아이콘은 하루에 한 번만 변경할 수 있습니다.', + message: '아이콘 변경은 24시간에 한 번만 가능합니다.', }); } const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted'); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index db690be..5ca0280 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -1,13 +1,23 @@ import { randomUUID } from 'node:crypto'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; -import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js'; +import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js'; // 유저 데이터 저장소를 메모리로 대체한 임시 구현. export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => { const usersByName = new Map(); const usersByOauthId = new Map(); const usersByEmail = new Map(); + const iconsById = new Map(); + + const nextRevision = (user: UserRecord, now: Date): string => + new Date( + Math.max( + now.getTime(), + new Date(user.createdAt).getTime() + 1, + (user.iconRevision ? new Date(user.iconRevision).getTime() : 0) + 1 + ) + ).toISOString(); return { async findById(id: string): Promise { @@ -165,14 +175,18 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp imageServer: number, updatedAt: Date, dayStart: Date, - consumeDailyQuota: boolean + consumeDailyQuota: boolean, + allowCutoffEquality = false ): Promise { for (const user of usersByName.values()) { if (user.id !== userId) { continue; } - if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) { - return null; + if (user.picture !== 'default.jpg' && user.iconUpdatedAt) { + const previousUpdate = new Date(user.iconUpdatedAt); + if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) { + return null; + } } const previousRevision = Math.max( new Date(user.createdAt).getTime(), @@ -191,6 +205,67 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp } throw new Error('User not found.'); }, + async listIcons(userId: string, includeRetired = false): Promise { + return [...iconsById.values()] + .filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); + }, + async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) { + const user = [...usersByName.values()].find((candidate) => candidate.id === userId); + if (!user) return { ok: false, reason: 'NOT_FOUND' }; + if (user.iconUpdatedAt && new Date(user.iconUpdatedAt) > uploadCutoff) { + return { ok: false, reason: 'COOLDOWN' }; + } + const active = [...iconsById.values()].filter((icon) => icon.userId === userId && !icon.retiredAt); + if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' }; + const revision = nextRevision(user, now); + const icon: UserIconRecord = { + id: randomUUID(), + userId, + picture, + imageServer, + createdAt: now.toISOString(), + }; + iconsById.set(icon.id, icon); + user.picture = picture; + user.imageServer = imageServer; + user.iconUpdatedAt = now.toISOString(); + user.iconRevision = revision; + return { ok: true, icon, revision }; + }, + async setPreferredIcon(userId, iconId, now) { + const user = [...usersByName.values()].find((candidate) => candidate.id === userId); + const icon = iconsById.get(iconId); + if (!user || !icon || icon.userId !== userId || icon.retiredAt) return null; + const revision = nextRevision(user, now); + user.picture = icon.picture; + user.imageServer = icon.imageServer; + user.iconRevision = revision; + return revision; + }, + async retireIconForWindow(userId, iconId, now, retireCutoff) { + const user = [...usersByName.values()].find((candidate) => candidate.id === userId); + if (!user) return { ok: false, reason: 'NOT_FOUND' }; + if (user.iconRetiredAt && new Date(user.iconRetiredAt) > retireCutoff) { + return { ok: false, reason: 'COOLDOWN' }; + } + const icon = iconsById.get(iconId); + if (!icon || icon.userId !== userId) return { ok: false, reason: 'NOT_FOUND' }; + if (icon.retiredAt) return { ok: false, reason: 'ALREADY_RETIRED' }; + icon.retiredAt = now.toISOString(); + const preferredChanged = user.picture === icon.picture; + if (preferredChanged) { + const fallback = [...iconsById.values()] + .filter((candidate) => candidate.userId === userId && !candidate.retiredAt) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0]; + user.picture = fallback?.picture ?? 'default.jpg'; + user.imageServer = fallback?.imageServer ?? 0; + } + const revision = nextRevision(user, now); + user.iconRevision = revision; + user.iconRetiredAt = now.toISOString(); + return { ok: true, icon, revision, preferredChanged }; + }, async resetProfileIcon(userId: string, requestedAt: Date): Promise { for (const user of usersByName.values()) { if (user.id !== userId) { diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 871ae4b..0d98d38 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -1,7 +1,14 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; -import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js'; +import type { + CreateUserInput, + UserIconRecord, + UserOAuthInfo, + UserRecord, + UserRepository, + UserSanctions, +} from './userRepository.js'; const readStringArray = (value: unknown): string[] => { if (!Array.isArray(value)) { @@ -46,6 +53,7 @@ const mapUser = (row: { iconUpdatedAt: Date | null; iconRevision: Date | null; profileIconResetAt: Date | null; + iconRetiredAt: Date | null; thirdPartyUse: boolean; termsAcceptedAt: Date | null; privacyAcceptedAt: Date | null; @@ -69,6 +77,7 @@ const mapUser = (row: { iconUpdatedAt: row.iconUpdatedAt?.toISOString(), iconRevision: row.iconRevision?.toISOString(), profileIconResetAt: row.profileIconResetAt?.toISOString(), + iconRetiredAt: row.iconRetiredAt?.toISOString(), thirdPartyUse: row.thirdPartyUse, termsAcceptedAt: row.termsAcceptedAt?.toISOString(), privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(), @@ -82,6 +91,22 @@ const mapUser = (row: { legacyGrade: readLegacyGrade(row.legacyData), }); +const mapIcon = (row: { + id: string; + userId: string; + picture: string; + imageServer: number; + createdAt: Date; + retiredAt: Date | null; +}): UserIconRecord => ({ + id: row.id, + userId: row.userId, + picture: row.picture, + imageServer: row.imageServer, + createdAt: row.createdAt.toISOString(), + retiredAt: row.retiredAt?.toISOString(), +}); + export const createPostgresUserRepository = ( prisma: GatewayPrismaClient, hasher: PasswordHasher = createSimplePasswordHasher() @@ -242,7 +267,8 @@ export const createPostgresUserRepository = ( imageServer: number, updatedAt: Date, dayStart: Date, - consumeDailyQuota: boolean + consumeDailyQuota: boolean, + allowCutoffEquality = false ): Promise { const rows = await prisma.$queryRaw>(GatewayPrisma.sql` UPDATE "app_user" @@ -262,11 +288,119 @@ export const createPostgresUserRepository = ( "picture" = 'default.jpg' OR "icon_updated_at" IS NULL OR "icon_updated_at" < ${dayStart} + OR (${allowCutoffEquality} AND "icon_updated_at" = ${dayStart}) ) RETURNING "icon_revision" AS "iconRevision" `); return rows[0]?.iconRevision.toISOString() ?? null; }, + async listIcons(userId: string, includeRetired = false): Promise { + const rows = await prisma.userIcon.findMany({ + where: { userId, ...(includeRetired ? {} : { retiredAt: null }) }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + return rows.map(mapIcon); + }, + async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) { + return prisma.$transaction(async (tx) => { + const users = await tx.$queryRaw< + Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }> + >(GatewayPrisma.sql` + SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt", + "icon_revision" AS "iconRevision" + FROM "app_user" WHERE "id" = ${userId} FOR UPDATE + `); + const user = users[0]; + if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const }; + if (user.iconUpdatedAt && user.iconUpdatedAt > uploadCutoff) { + return { ok: false as const, reason: 'COOLDOWN' as const }; + } + const activeCount = await tx.userIcon.count({ where: { userId, retiredAt: null } }); + if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const }; + const revision = new Date( + Math.max(now.getTime(), user.iconRevision?.getTime() ?? 0, user.createdAt.getTime()) + + (now.getTime() <= (user.iconRevision?.getTime() ?? 0) ? 1 : 0) + ); + const icon = await tx.userIcon.create({ data: { userId, picture, imageServer, createdAt: now } }); + await tx.appUser.update({ + where: { id: userId }, + data: { picture, imageServer, iconUpdatedAt: now, iconRevision: revision }, + }); + return { ok: true as const, icon: mapIcon(icon), revision: revision.toISOString() }; + }); + }, + async setPreferredIcon(userId, iconId, now) { + return prisma.$transaction(async (tx) => { + const users = await tx.$queryRaw>( + GatewayPrisma.sql`SELECT "created_at" AS "createdAt", "icon_revision" AS "iconRevision" + FROM "app_user" WHERE "id" = ${userId} FOR UPDATE` + ); + const user = users[0]; + if (!user) return null; + const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId, retiredAt: null } }); + if (!icon) return null; + const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0); + const revision = new Date(Math.max(now.getTime(), previous + 1)); + await tx.appUser.update({ + where: { id: userId }, + data: { picture: icon.picture, imageServer: icon.imageServer, iconRevision: revision }, + }); + return revision.toISOString(); + }); + }, + async retireIconForWindow(userId, iconId, now, retireCutoff) { + return prisma.$transaction(async (tx) => { + const users = await tx.$queryRaw< + Array<{ + picture: string; + createdAt: Date; + iconRevision: Date | null; + iconRetiredAt: Date | null; + }> + >(GatewayPrisma.sql` + SELECT "picture", "created_at" AS "createdAt", "icon_revision" AS "iconRevision", + "icon_retired_at" AS "iconRetiredAt" + FROM "app_user" WHERE "id" = ${userId} FOR UPDATE + `); + const user = users[0]; + if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const }; + if (user.iconRetiredAt && user.iconRetiredAt > retireCutoff) { + return { ok: false as const, reason: 'COOLDOWN' as const }; + } + const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId } }); + if (!icon) return { ok: false as const, reason: 'NOT_FOUND' as const }; + if (icon.retiredAt) return { ok: false as const, reason: 'ALREADY_RETIRED' as const }; + const retired = await tx.userIcon.update({ where: { id: icon.id }, data: { retiredAt: now } }); + const preferredChanged = user.picture === icon.picture; + const fallback = preferredChanged + ? await tx.userIcon.findFirst({ + where: { userId, retiredAt: null, id: { not: icon.id } }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }) + : null; + const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0); + const revision = new Date(Math.max(now.getTime(), previous + 1)); + await tx.appUser.update({ + where: { id: userId }, + data: { + iconRetiredAt: now, + iconRevision: revision, + ...(preferredChanged + ? { + picture: fallback?.picture ?? 'default.jpg', + imageServer: fallback?.imageServer ?? 0, + } + : {}), + }, + }); + return { + ok: true as const, + icon: mapIcon(retired), + revision: revision.toISOString(), + preferredChanged, + }; + }); + }, async resetProfileIcon(userId: string, requestedAt: Date): Promise { return prisma.$transaction(async (tx) => { const rows = await tx.$queryRaw< diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index dfde2a7..5a4b6a0 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -13,6 +13,7 @@ export interface UserRecord { iconUpdatedAt?: string; iconRevision?: string; profileIconResetAt?: string; + iconRetiredAt?: string; thirdPartyUse: boolean; termsAcceptedAt?: string; privacyAcceptedAt?: string; @@ -26,6 +27,22 @@ export interface UserRecord { legacyGrade?: number; } +export interface UserIconRecord { + id: string; + userId: string; + picture: string; + imageServer: number; + createdAt: string; + retiredAt?: string; +} + +export type AddUserIconResult = + { ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' }; + +export type RetireUserIconResult = + | { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean } + | { ok: false; reason: 'COOLDOWN' | 'NOT_FOUND' | 'ALREADY_RETIRED' }; + export interface PublicUser { id: string; username: string; @@ -110,8 +127,20 @@ export interface UserRepository { imageServer: number, updatedAt: Date, dayStart: Date, - consumeDailyQuota: boolean + consumeDailyQuota: boolean, + allowCutoffEquality?: boolean ): Promise; + listIcons(userId: string, includeRetired?: boolean): Promise; + addIconForWindow( + userId: string, + picture: string, + imageServer: number, + now: Date, + uploadCutoff: Date, + maxActive: number + ): Promise; + setPreferredIcon(userId: string, iconId: string, now: Date): Promise; + retireIconForWindow(userId: string, iconId: string, now: Date, retireCutoff: Date): Promise; resetProfileIcon(userId: string, requestedAt: Date): Promise; setThirdPartyUse(userId: string, allowed: boolean): Promise; scheduleDeletion(userId: string, deleteAfter: Date): Promise; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 551280d..ffd91d4 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -658,6 +658,7 @@ export const appRouter = router({ } const now = new Date(); const accountIcon = resolveEffectiveAccountIcon(user); + const accountIcons = await ctx.users.listIcons(user.id); const payload = { version: 1, profile: gameSession.profile, @@ -676,6 +677,12 @@ export const appRouter = router({ roles: user.roles, createdAt: user.createdAt, legacyMemberNo: user.legacyMemberNo, + icons: accountIcons.map((icon) => ({ + id: icon.id, + picture: icon.picture, + imageServer: icon.imageServer, + createdAt: icon.createdAt, + })), }, sanctions: user.sanctions, identity: { diff --git a/app/gateway-api/test/accountIconDailyCas.integration.test.ts b/app/gateway-api/test/accountIconDailyCas.integration.test.ts index 9d5603c..68da096 100644 --- a/app/gateway-api/test/accountIconDailyCas.integration.test.ts +++ b/app/gateway-api/test/accountIconDailyCas.integration.test.ts @@ -154,4 +154,48 @@ integration('account icon daily PostgreSQL CAS', () => { sanctions: { warningCount: 1 }, }); }); + + it('serializes the five-slot library and preserves retired rows', async () => { + const users = createPostgresUserRepository(db); + const start = new Date('2026-08-03T00:00:00.000Z'); + await db.userIcon.deleteMany({ where: { userId } }); + await db.appUser.update({ + where: { id: userId }, + data: { picture: 'default.jpg', imageServer: 0, iconUpdatedAt: null, iconRetiredAt: null }, + }); + + for (let index = 0; index < 5; index += 1) { + const now = new Date(start.getTime() + index * 86_400_000); + await expect( + users.addIconForWindow( + userId, + `postgres-library-${index}.png`, + 1, + now, + new Date(now.getTime() - 86_400_000), + 5 + ) + ).resolves.toMatchObject({ ok: true }); + } + await expect( + users.addIconForWindow( + userId, + 'postgres-library-sixth.png', + 1, + new Date(start.getTime() + 5 * 86_400_000), + new Date(start.getTime() + 4 * 86_400_000), + 5 + ) + ).resolves.toEqual({ ok: false, reason: 'LIMIT' }); + + const icons = await users.listIcons(userId); + const retiredAt = new Date(start.getTime() + 6 * 86_400_000); + await expect( + users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 7 * 86_400_000)) + ).resolves.toMatchObject({ ok: true }); + await expect(users.listIcons(userId)).resolves.toHaveLength(4); + await expect(users.listIcons(userId, true)).resolves.toContainEqual( + expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() }) + ); + }); }); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 0c0c451..0c373f4 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -781,7 +781,7 @@ describe('account self service', () => { expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted'); }); - it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => { + it('uses a rolling 24-hour upload window and preserves delete-to-upload behavior', async () => { const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-')); const png = await sharp({ create: { @@ -809,11 +809,17 @@ describe('account self service', () => { }); vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z')); - const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken }); - expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z'); + await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + }); + + vi.setSystemTime(new Date('2026-08-01T00:00:00.000Z')); + const nextSession = await sessions.createSession(user); + const deleted = await caller.account.deleteIcon({ sessionToken: nextSession.sessionToken }); + expect(deleted.revision).toBe('2026-08-01T00:00:00.000Z'); const changed = await caller.account.changeIcon({ - sessionToken: session.sessionToken, + sessionToken: nextSession.sessionToken, imageData: `data:image/png;base64,${png.toString('base64')}`, }); expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime()); diff --git a/app/gateway-api/test/userIconLibrary.test.ts b/app/gateway-api/test/userIconLibrary.test.ts new file mode 100644 index 0000000..b1e4dd4 --- /dev/null +++ b/app/gateway-api/test/userIconLibrary.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('user icon library', () => { + it('keeps five immutable active icons and enforces the rolling upload window', async () => { + const users = createInMemoryUserRepository(); + const user = await users.createUser({ username: 'five-icons', password: 'password' }); + const start = new Date('2026-08-01T00:00:00.000Z'); + + for (let index = 0; index < 5; index += 1) { + const now = new Date(start.getTime() + index * DAY_MS); + const stored = await users.addIconForWindow( + user.id, + `immutable-${index}.png`, + 1, + now, + new Date(now.getTime() - DAY_MS), + 5 + ); + expect(stored.ok).toBe(true); + if (index === 0) { + const blocked = await users.addIconForWindow( + user.id, + 'too-soon.png', + 1, + new Date(now.getTime() + DAY_MS - 1), + new Date(now.getTime() - 1), + 5 + ); + expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' }); + } + } + + const icons = await users.listIcons(user.id); + expect(icons.map((icon) => icon.picture)).toEqual([ + 'immutable-0.png', + 'immutable-1.png', + 'immutable-2.png', + 'immutable-3.png', + 'immutable-4.png', + ]); + const overLimit = await users.addIconForWindow( + user.id, + 'sixth.png', + 1, + new Date(start.getTime() + 5 * DAY_MS), + new Date(start.getTime() + 4 * DAY_MS), + 5 + ); + expect(overLimit).toEqual({ ok: false, reason: 'LIMIT' }); + }); + + it('retires without deleting the durable record and allows retirement only every seven days', async () => { + const users = createInMemoryUserRepository(); + const user = await users.createUser({ username: 'retire-icons', password: 'password' }); + const firstAt = new Date('2026-08-01T00:00:00.000Z'); + const first = await users.addIconForWindow( + user.id, + 'hall-of-fame.png', + 1, + firstAt, + new Date(firstAt.getTime() - DAY_MS), + 5 + ); + expect(first.ok).toBe(true); + if (!first.ok) return; + const secondAt = new Date(firstAt.getTime() + DAY_MS); + const second = await users.addIconForWindow( + user.id, + 'next.png', + 1, + secondAt, + new Date(secondAt.getTime() - DAY_MS), + 5 + ); + expect(second.ok).toBe(true); + if (!second.ok) return; + + const retired = await users.retireIconForWindow( + user.id, + first.icon.id, + secondAt, + new Date(secondAt.getTime() - 7 * DAY_MS) + ); + expect(retired.ok).toBe(true); + expect(await users.listIcons(user.id)).toHaveLength(1); + expect(await users.listIcons(user.id, true)).toContainEqual( + expect.objectContaining({ picture: 'hall-of-fame.png', retiredAt: secondAt.toISOString() }) + ); + + const blocked = await users.retireIconForWindow( + user.id, + second.icon.id, + new Date(secondAt.getTime() + 7 * DAY_MS - 1), + new Date(secondAt.getTime() - 1) + ); + expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' }); + }); +}); diff --git a/app/gateway-frontend/e2e/account-icon-sync.spec.ts b/app/gateway-frontend/e2e/account-icon-sync.spec.ts index fd2f358..0f3ba14 100644 --- a/app/gateway-frontend/e2e/account-icon-sync.spec.ts +++ b/app/gateway-frontend/e2e/account-icon-sync.spec.ts @@ -62,6 +62,8 @@ const activeProfiles = [ const installFixture = async (page: Page, options: FixtureOptions = {}) => { let deleteIconCount = 0; + let preferredIconCount = 0; + let retireIconCount = 0; let hweAdjustCount = 0; const operations = new Map([ ['che:903', []], @@ -83,6 +85,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => { oauthType: 'NONE', createdAt: '2026-07-30T00:00:00.000Z', iconUrl: '/gateway/api/user-icons/old.png', + icons: [ + { + id: '3f804277-584f-4f44-b39c-9ecf40d1ed31', + picture: 'old.png', + imageServer: 1, + createdAt: '2026-07-30T00:00:00.000Z', + retiredAt: null, + url: '/gateway/api/user-icons/old.png', + }, + { + id: '9bc328b0-3fc8-44ec-a845-287e438e8edf', + picture: 'second.png', + imageServer: 1, + createdAt: '2026-07-31T00:00:00.000Z', + retiredAt: null, + url: '/gateway/api/user-icons/second.png', + }, + ], + preferredPicture: 'old.png', + maxActiveIcons: 5, + nextUploadAt: null, + nextRetireAt: null, thirdPartyUse: false, deleteAfter: null, }); @@ -96,6 +120,20 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => { flushPublished: true, }); } + if (operation === 'account.setPreferredIcon') { + preferredIconCount += 1; + return response({ ok: true, revision: '2026-08-01T00:00:00.001Z', flushPublished: true }); + } + if (operation === 'account.retireIcon') { + retireIconCount += 1; + return response({ + ok: true, + revision: '2026-08-01T00:00:00.002Z', + preferredChanged: false, + iconUrl: '/gateway/api/user-icons/old.png', + flushPublished: true, + }); + } if (operation === 'account.deleteIcon') { deleteIconCount += 1; return response({ @@ -182,9 +220,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => { return { operations, deleteIconCount: () => deleteIconCount, + preferredIconCount: () => preferredIconCount, + retireIconCount: () => retireIconCount, }; }; +test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }) => { + const fixture = await installFixture(page); + await page.goto('account'); + await expect(page.locator('.account-icon-card')).toHaveCount(2); + await expect(page.getByText('2 / 5개')).toBeVisible(); + + await page.getByRole('button', { name: '대표로 설정' }).click(); + await expect.poll(fixture.preferredIconCount).toBe(1); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('과거 기록의 이미지는 보존됩니다'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '목록에서 내리기' }).first().click(); + await expect.poll(fixture.retireIconCount).toBe(1); +}); + const uploadIcon = async (page: Page): Promise => { await page.locator('input[type="file"]').setInputFiles({ name: 'new-icon.png', diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 62f1f86..a2943cb 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -303,10 +303,36 @@ const changeIcon = async (event?: Event): Promise => { successMessage.value = result.flushPublished ? '전용 아이콘을 변경했습니다.' : '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.'; + await loadAccount(); openIconServerModal(result.profiles, returnFocus); }); }; +const setPreferredIcon = async (iconId: string): Promise => { + await runAction(async () => { + const token = sessionToken(); + if (!token) throw new Error('로그인이 필요합니다.'); + await trpc.account.setPreferredIcon.mutate({ sessionToken: token, iconId }); + successMessage.value = '대표 전용 아이콘을 변경했습니다.'; + await loadAccount(); + }); +}; + +const retireIcon = async (iconId: string): Promise => { + if ( + !window.confirm('목록에서 내린 아이콘은 다시 선택할 수 없습니다. 과거 기록의 이미지는 보존됩니다. 계속할까요?') + ) { + return; + } + await runAction(async () => { + const token = sessionToken(); + if (!token) throw new Error('로그인이 필요합니다.'); + await trpc.account.retireIcon.mutate({ sessionToken: token, iconId }); + successMessage.value = '전용 아이콘을 목록에서 내렸습니다. 기존 URL과 과거 기록은 보존됩니다.'; + await loadAccount(); + }); +}; + const deleteIcon = async (event?: Event): Promise => { const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null; if (!window.confirm('아이콘을 제거할까요?')) return; @@ -497,6 +523,41 @@ onBeforeUnmount(() => { + + 전콘
목록 + + +

+ {{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 · + 목록에서 내리기는 7일에 1회 +

+ + @@ -1043,6 +1104,29 @@ onBeforeUnmount(() => { outline: 0; } +.account-icon-library { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; +} + +.account-icon-card { + display: grid; + width: 104px; + justify-items: center; + gap: 4px; +} + +.preferred-label { + color: #ffbf00; + font-weight: 700; +} + +.icon-policy { + margin: 0 8px 8px; +} + @media (max-width: 600px) { #account-container { margin-left: 0; diff --git a/packages/common/src/auth/gameToken.ts b/packages/common/src/auth/gameToken.ts index 88adad1..0e4f360 100644 --- a/packages/common/src/auth/gameToken.ts +++ b/packages/common/src/auth/gameToken.ts @@ -32,6 +32,14 @@ export interface GatewayUserInfo { canUseGeneralPicture?: boolean; createdAt?: string; legacyMemberNo?: number; + icons?: GatewayUserIconInfo[]; +} + +export interface GatewayUserIconInfo { + id: string; + picture: string; + imageServer: number; + createdAt: string; } export interface GameSessionTokenPayload { @@ -98,6 +106,20 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa (user.profileIconResetAt !== undefined && (typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) || (user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') || + (user.icons !== undefined && + (!Array.isArray(user.icons) || + user.icons.length > 5 || + user.icons.some( + (icon) => + !icon || + typeof icon !== 'object' || + typeof icon.id !== 'string' || + typeof icon.picture !== 'string' || + !Number.isSafeInteger(icon.imageServer) || + icon.imageServer < 0 || + typeof icon.createdAt !== 'string' || + !isCanonicalIsoTimestamp(icon.createdAt) + ))) || (user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0)) ) { return null; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index a5448ba..3828dd1 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -212,6 +212,7 @@ export type TurnDaemonCommand = picture: string; imageServer: number; iconRevision: string; + enforceCooldown?: boolean; } | { type: 'joinCreateGeneral'; @@ -254,6 +255,9 @@ export type TurnDaemonCommand = uniqueName: string; personality: string; seedOwnerIdentity: string | number; + ownerPicture?: string; + ownerImageServer?: number; + ownerIconRevision?: string; } | { type: 'selectPoolReselect'; @@ -534,8 +538,9 @@ export type TurnDaemonCommandResult = | { type: 'adjustGeneralIcon'; ok: false; - code: 'CONFLICT' | 'PRECONDITION_FAILED'; + code: 'CONFLICT' | 'PRECONDITION_FAILED' | 'TOO_MANY_REQUESTS'; reason: string; + availableAt?: string; } | { type: 'joinCreateGeneral'; diff --git a/packages/infra/prisma/gateway-migrations/20260801000000_add_user_icon_library/migration.sql b/packages/infra/prisma/gateway-migrations/20260801000000_add_user_icon_library/migration.sql new file mode 100644 index 0000000..f97d7cc --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260801000000_add_user_icon_library/migration.sql @@ -0,0 +1,29 @@ +ALTER TABLE "app_user" +ADD COLUMN "icon_retired_at" TIMESTAMP(3); + +CREATE TABLE "user_icon" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" TEXT NOT NULL, + "picture" TEXT NOT NULL, + "image_server" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "retired_at" TIMESTAMP(3), + CONSTRAINT "user_icon_pkey" PRIMARY KEY ("id"), + CONSTRAINT "user_icon_user_id_fkey" FOREIGN KEY ("user_id") + REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "user_icon_picture_key" ON "user_icon"("picture"); +CREATE INDEX "user_icon_user_id_retired_at_created_at_idx" +ON "user_icon"("user_id", "retired_at", "created_at"); + +-- 기존 전콘의 파일명과 URL을 바꾸지 않고 활성 목록의 첫 항목으로 올립니다. +INSERT INTO "user_icon" ("user_id", "picture", "image_server", "created_at") +SELECT + "id", + "picture", + "image_server", + COALESCE("icon_updated_at", "created_at") +FROM "app_user" +WHERE "picture" <> 'default.jpg' AND "image_server" > 0 +ON CONFLICT ("picture") DO NOTHING; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 7d65690..f981218 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -59,36 +59,51 @@ enum GatewaySourceMode { } model AppUser { - id String @id @default(uuid()) - loginId String @unique @map("login_id") - displayName String @unique @map("display_name") - passwordHash String @map("password_hash") - passwordSalt String @map("password_salt") - roles Json @default(dbgenerated("'[]'::jsonb")) - sanctions Json @default(dbgenerated("'{}'::jsonb")) - oauthType OAuthType @default(NONE) @map("oauth_type") - oauthId String? @unique @map("oauth_id") - email String? @unique - oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") - picture String @default("default.jpg") - imageServer Int @default(0) @map("image_server") - iconUpdatedAt DateTime? @map("icon_updated_at") - iconRevision DateTime? @map("icon_revision") - profileIconResetAt DateTime? @map("profile_icon_reset_at") - thirdPartyUse Boolean @default(true) @map("third_party_use") - termsAcceptedAt DateTime? @map("terms_accepted_at") - privacyAcceptedAt DateTime? @map("privacy_accepted_at") - kakaoVerifiedAt DateTime? @map("kakao_verified_at") - kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at") - deleteAfter DateTime? @map("delete_after") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - lastLoginAt DateTime? @map("last_login_at") - legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data") + id String @id @default(uuid()) + loginId String @unique @map("login_id") + displayName String @unique @map("display_name") + passwordHash String @map("password_hash") + passwordSalt String @map("password_salt") + roles Json @default(dbgenerated("'[]'::jsonb")) + sanctions Json @default(dbgenerated("'{}'::jsonb")) + oauthType OAuthType @default(NONE) @map("oauth_type") + oauthId String? @unique @map("oauth_id") + email String? @unique + oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") + picture String @default("default.jpg") + imageServer Int @default(0) @map("image_server") + iconUpdatedAt DateTime? @map("icon_updated_at") + iconRevision DateTime? @map("icon_revision") + profileIconResetAt DateTime? @map("profile_icon_reset_at") + iconRetiredAt DateTime? @map("icon_retired_at") + thirdPartyUse Boolean @default(true) @map("third_party_use") + termsAcceptedAt DateTime? @map("terms_accepted_at") + privacyAcceptedAt DateTime? @map("privacy_accepted_at") + kakaoVerifiedAt DateTime? @map("kakao_verified_at") + kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at") + deleteAfter DateTime? @map("delete_after") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastLoginAt DateTime? @map("last_login_at") + legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data") + icons UserIcon[] @@map("app_user") } +model UserIcon { + id String @id @default(uuid()) + userId String @map("user_id") + user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade) + picture String @unique + imageServer Int @default(1) @map("image_server") + createdAt DateTime @default(now()) @map("created_at") + retiredAt DateTime? @map("retired_at") + + @@index([userId, retiredAt, createdAt]) + @@map("user_icon") +} + model LegacyMemberLog { id BigInt @id memberNo Int @map("member_no")