feat: add user icon library and in-game selection
This commit is contained in:
@@ -174,13 +174,33 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generalRouter = router({
|
export const generalRouter = router({
|
||||||
adjustIcon: engineAuthedProcedure.mutation(({ ctx }) => {
|
adjustIcon: engineAuthedProcedure
|
||||||
const userId = ctx.auth?.user.id;
|
.input(
|
||||||
if (!userId) {
|
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
)
|
||||||
}
|
.mutation(({ ctx, input }) => {
|
||||||
return adjustAccountIconForUser(ctx, userId);
|
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 }) => {
|
me: authedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -296,6 +316,12 @@ export const generalRouter = router({
|
|||||||
item: normalizeItemCode(general.itemCode),
|
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,
|
city,
|
||||||
nation,
|
nation,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -329,6 +329,8 @@ export const joinRouter = router({
|
|||||||
id: ctx.auth?.user.id ?? '',
|
id: ctx.auth?.user.id ?? '',
|
||||||
displayName: ctx.auth?.user.displayName ?? '',
|
displayName: ctx.auth?.user.displayName ?? '',
|
||||||
canCreateGeneral: ctx.auth?.identity?.canCreateGeneral !== false,
|
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],
|
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
|
||||||
warSpecials,
|
warSpecials,
|
||||||
@@ -383,6 +385,7 @@ export const joinRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
uniqueName: z.string().min(1).max(20),
|
uniqueName: z.string().min(1).max(20),
|
||||||
personality: z.string().min(1),
|
personality: z.string().min(1),
|
||||||
|
iconId: z.string().uuid().optional(),
|
||||||
clientRequestId: z.string().uuid().optional(),
|
clientRequestId: z.string().uuid().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -392,6 +395,10 @@ export const joinRouter = router({
|
|||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const userId = auth.user.id;
|
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) {
|
if (auth.identity?.canCreateGeneral === false) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
@@ -407,6 +414,13 @@ export const joinRouter = router({
|
|||||||
uniqueName: input.uniqueName,
|
uniqueName: input.uniqueName,
|
||||||
personality: input.personality,
|
personality: input.personality,
|
||||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||||
|
...(selectedIcon
|
||||||
|
? {
|
||||||
|
ownerPicture: selectedIcon.picture,
|
||||||
|
ownerImageServer: selectedIcon.imageServer,
|
||||||
|
ownerIconRevision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return resolveSelectionCommandResult(result, 'selectPoolCreate');
|
return resolveSelectionCommandResult(result, 'selectPoolCreate');
|
||||||
}),
|
}),
|
||||||
@@ -446,6 +460,7 @@ export const joinRouter = router({
|
|||||||
strength: z.number().int(),
|
strength: z.number().int(),
|
||||||
intel: z.number().int(),
|
intel: z.number().int(),
|
||||||
pic: z.boolean(),
|
pic: z.boolean(),
|
||||||
|
iconId: z.string().uuid().optional(),
|
||||||
character: zJoinPersonality,
|
character: zJoinPersonality,
|
||||||
clientRequestId: z.string().uuid().optional(),
|
clientRequestId: z.string().uuid().optional(),
|
||||||
inheritSpecial: z.string().optional(),
|
inheritSpecial: z.string().optional(),
|
||||||
@@ -466,7 +481,19 @@ export const joinRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const userId = auth.user.id;
|
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 commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||||
const result = await requestJoinCreateCommand(ctx, {
|
const result = await requestJoinCreateCommand(ctx, {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
|
|||||||
@@ -39,14 +39,19 @@ export const loadAuthoritativeAccountIcon = async (
|
|||||||
|
|
||||||
export const adjustAccountIconForUser = async (
|
export const adjustAccountIconForUser = async (
|
||||||
ctx: GameApiContext,
|
ctx: GameApiContext,
|
||||||
userId: string
|
userId: string,
|
||||||
|
selected?: AccountIconProjection,
|
||||||
|
enforceCooldown = true,
|
||||||
|
requestKey?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
ok: true;
|
ok: true;
|
||||||
generalId: number | null;
|
generalId: number | null;
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
|
||||||
const requestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
const requestId = selected
|
||||||
|
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
|
||||||
|
: `general:adjustIcon:${userId}:${projection.revision}`;
|
||||||
try {
|
try {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralIcon',
|
type: 'adjustGeneralIcon',
|
||||||
@@ -55,6 +60,7 @@ export const adjustAccountIconForUser = async (
|
|||||||
picture: projection.picture,
|
picture: projection.picture,
|
||||||
imageServer: projection.imageServer,
|
imageServer: projection.imageServer,
|
||||||
iconRevision: projection.revision,
|
iconRevision: projection.revision,
|
||||||
|
enforceCooldown,
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -423,6 +423,38 @@ describe('appRouter', () => {
|
|||||||
picture: 'latest.png',
|
picture: 'latest.png',
|
||||||
imageServer: 1,
|
imageServer: 1,
|
||||||
iconRevision: currentAccountIcon.revision,
|
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 () => {
|
it('queues turn daemon run commands', async () => {
|
||||||
const transport = new InMemoryTurnDaemonTransport();
|
const transport = new InMemoryTurnDaemonTransport();
|
||||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ const zAdjustGeneralIcon = z
|
|||||||
picture: z.string().min(1),
|
picture: z.string().min(1),
|
||||||
imageServer: z.number().int().nonnegative(),
|
imageServer: z.number().int().nonnegative(),
|
||||||
iconRevision: z.string().refine(isCanonicalIsoTimestamp),
|
iconRevision: z.string().refine(isCanonicalIsoTimestamp),
|
||||||
|
enforceCooldown: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -312,6 +313,9 @@ const zSelectPoolCreate = z
|
|||||||
uniqueName: z.string().min(1).max(20),
|
uniqueName: z.string().min(1).max(20),
|
||||||
personality: z.string().min(1),
|
personality: z.string().min(1),
|
||||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
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();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -515,6 +515,9 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
personality: string;
|
personality: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
seedOwnerIdentity?: string | number;
|
seedOwnerIdentity?: string | number;
|
||||||
|
ownerPicture?: string;
|
||||||
|
ownerImageServer?: number;
|
||||||
|
ownerIconRevision?: string;
|
||||||
}): Promise<{ ok: true; generalId: number }> => {
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||||
requirePoolWorld(worldState);
|
requirePoolWorld(worldState);
|
||||||
@@ -555,7 +558,10 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
);
|
);
|
||||||
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
||||||
const showImgLevel = asNumber(config.showImgLevel, 0);
|
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 =
|
const defaultSpecialWar =
|
||||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||||
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
|
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
|
||||||
@@ -576,7 +582,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
bornYear: worldState.currentYear - age,
|
bornYear: worldState.currentYear - age,
|
||||||
deadYear: worldState.currentYear + 60,
|
deadYear: worldState.currentYear + 60,
|
||||||
picture,
|
picture,
|
||||||
imageServer: info.imgsvr,
|
imageServer,
|
||||||
stats: {
|
stats: {
|
||||||
leadership: info.leadership,
|
leadership: info.leadership,
|
||||||
strength: info.strength,
|
strength: info.strength,
|
||||||
@@ -630,6 +636,9 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
next_change: nextChangeAt.toISOString(),
|
next_change: nextChangeAt.toISOString(),
|
||||||
nextChangeAt: nextChangeAt.toISOString(),
|
nextChangeAt: nextChangeAt.toISOString(),
|
||||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||||
|
...(useOwnerPicture && options.ownerIconRevision
|
||||||
|
? { accountIconUpdatedAt: options.ownerIconRevision }
|
||||||
|
: {}),
|
||||||
npc_org: 0,
|
npc_org: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ async function handleSelectPoolCreate(
|
|||||||
uniqueName: command.uniqueName,
|
uniqueName: command.uniqueName,
|
||||||
personality: command.personality,
|
personality: command.personality,
|
||||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||||
|
...(command.ownerPicture ? { ownerPicture: command.ownerPicture } : {}),
|
||||||
|
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||||
|
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -711,7 +714,7 @@ async function handleAdjustGeneralIcon(
|
|||||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
|
||||||
): Promise<TurnDaemonCommandResult> {
|
): Promise<TurnDaemonCommandResult> {
|
||||||
const db = requireCommandDatabase(ctx);
|
const db = requireCommandDatabase(ctx);
|
||||||
await resolveCommandAcceptedAt(db, command);
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const general = ctx.world
|
const general = ctx.world
|
||||||
.listGenerals()
|
.listGenerals()
|
||||||
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
|
.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;
|
const currentRevision = general.meta.accountIconUpdatedAt;
|
||||||
if (currentRevision !== undefined) {
|
if (currentRevision !== undefined) {
|
||||||
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
|
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
|
||||||
|
|||||||
@@ -88,11 +88,11 @@ const buildWorld = (generals: TurnGeneral[]) => {
|
|||||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
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: {
|
inputEvent: {
|
||||||
findUnique: vi.fn(async () => ({
|
findUnique: vi.fn(async () => ({
|
||||||
createdAt: new Date('2026-07-31T09:00:00.000Z'),
|
createdAt: acceptedAt,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'adjustGeneralIcon',
|
eventType: 'adjustGeneralIcon',
|
||||||
@@ -107,6 +107,7 @@ const command = (
|
|||||||
picture: string;
|
picture: string;
|
||||||
imageServer: number;
|
imageServer: number;
|
||||||
iconRevision: string;
|
iconRevision: string;
|
||||||
|
enforceCooldown: boolean;
|
||||||
}> = {}
|
}> = {}
|
||||||
) => ({
|
) => ({
|
||||||
type: 'adjustGeneralIcon' as const,
|
type: 'adjustGeneralIcon' as const,
|
||||||
@@ -196,4 +197,26 @@ describe('adjustGeneralIcon ENGINE command', () => {
|
|||||||
);
|
);
|
||||||
expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type FixtureState = {
|
|||||||
nationNoticeInput?: string;
|
nationNoticeInput?: string;
|
||||||
settingMutations: Array<Record<string, unknown>>;
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
accessPages: string[];
|
accessPages: string[];
|
||||||
|
iconChoices?: Array<{ id: string; picture: string; imageServer: number; createdAt: string }>;
|
||||||
|
adjustIconInputs?: Array<Record<string, unknown>>;
|
||||||
|
joinConfig?: Record<string, unknown>;
|
||||||
|
createGeneralInputs?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TrpcRequestPayload = {
|
type TrpcRequestPayload = {
|
||||||
@@ -79,6 +83,9 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
myset: state.myset,
|
myset: state.myset,
|
||||||
},
|
},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
|
iconChoices: state.iconChoices ?? [],
|
||||||
|
canChangeIcon: true,
|
||||||
|
iconChangeAvailableAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const battleCenter = (state: FixtureState) => ({
|
const battleCenter = (state: FixtureState) => ({
|
||||||
@@ -168,8 +175,13 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
const jsonInput =
|
const jsonInput =
|
||||||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
if (operation === 'lobby.info')
|
||||||
if (operation === 'join.getConfig') return response({});
|
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') {
|
if (operation === 'general.me') {
|
||||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||||
return response(myGeneral(state));
|
return response(myGeneral(state));
|
||||||
@@ -307,6 +319,10 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
state.myset = Math.max(0, state.myset - 1);
|
state.myset = Math.max(0, state.myset - 1);
|
||||||
return response({ ok: true });
|
return response({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (operation === 'general.adjustIcon') {
|
||||||
|
state.adjustIconInputs?.push(jsonInput);
|
||||||
|
return response({ ok: true, generalId: 7, updated: true });
|
||||||
|
}
|
||||||
if (operation === 'public.recordAccess') {
|
if (operation === 'public.recordAccess') {
|
||||||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||||
if (pageName) state.accessPages.push(pageName);
|
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);
|
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 }) => {
|
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
permission: 'head',
|
permission: 'head',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const error = ref<string | null>(null);
|
|||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
const joinConfig = ref<JoinConfig | null>(null);
|
const joinConfig = ref<JoinConfig | null>(null);
|
||||||
|
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
|
||||||
const activeTab = ref<'create' | 'possess'>('create');
|
const activeTab = ref<'create' | 'possess'>('create');
|
||||||
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
||||||
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
|
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
|
||||||
@@ -54,6 +55,7 @@ const form = ref<JoinForm>({
|
|||||||
intel: 0,
|
intel: 0,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
pic: true,
|
pic: true,
|
||||||
|
iconId: undefined,
|
||||||
inheritBonusStat: [0, 0, 0],
|
inheritBonusStat: [0, 0, 0],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -401,6 +403,7 @@ const loadConfig = async () => {
|
|||||||
form.value = pending.input;
|
form.value = pending.input;
|
||||||
} else {
|
} else {
|
||||||
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||||
|
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
|
||||||
applyBalancedStats();
|
applyBalancedStats();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -644,6 +647,23 @@ onUnmounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="accountIcons.length" class="icon-choice">
|
||||||
|
<div class="bonus-title">전용 아이콘 선택</div>
|
||||||
|
<label class="icon-option"> <input v-model="form.pic" type="checkbox" /> 전용 아이콘 사용 </label>
|
||||||
|
<div v-if="form.pic" class="icon-list" role="radiogroup" aria-label="전용 아이콘 선택">
|
||||||
|
<label v-for="icon in accountIcons" :key="icon.id" class="icon-card">
|
||||||
|
<input v-model="form.iconId" type="radio" :value="icon.id" />
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl({ picture: icon.picture, imageServer: icon.imageServer })"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stat-actions">
|
<div class="stat-actions">
|
||||||
<button @click="applyRandomStats">랜덤형</button>
|
<button @click="applyRandomStats">랜덤형</button>
|
||||||
<button @click="applyFocusedStats('leadership')">통솔형</button>
|
<button @click="applyFocusedStats('leadership')">통솔형</button>
|
||||||
@@ -1381,4 +1401,17 @@ onUnmounted(() => {
|
|||||||
.ghost {
|
.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
const customCss = ref('');
|
const customCss = ref('');
|
||||||
|
const selectedIconId = ref('');
|
||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
let cssTimer: number | null = null;
|
let cssTimer: number | null = null;
|
||||||
@@ -132,6 +133,7 @@ const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | nu
|
|||||||
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
|
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
|
||||||
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
|
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
|
||||||
]);
|
]);
|
||||||
|
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
|
|
||||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
|
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
|
||||||
@@ -205,6 +207,10 @@ const loadPage = async (resetImmediateActionIds = true) => {
|
|||||||
dieOnPrestartStatus.value = prestartStatus;
|
dieOnPrestartStatus.value = prestartStatus;
|
||||||
if (general) {
|
if (general) {
|
||||||
Object.assign(form, general.settings);
|
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)));
|
await Promise.all(logTypes.map((type) => loadLog(type)));
|
||||||
if (resetImmediateActionIds) {
|
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 () => {
|
const saveSettings = async () => {
|
||||||
if (!canSave.value) return;
|
if (!canSave.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -427,6 +447,25 @@ onMounted(() => {
|
|||||||
휴가 신청
|
휴가 신청
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
|
||||||
|
전용 아이콘 변경 (24시간에 1회)<br />
|
||||||
|
<span v-if="data.iconChangeAvailableAt" class="hint">
|
||||||
|
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||||
|
</span>
|
||||||
|
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||||
|
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||||
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(icon)"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="action-button" type="button" @click="changeGeneralIcon">아이콘 변경</button>
|
||||||
|
</div>
|
||||||
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||||
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||||
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
||||||
@@ -759,6 +798,18 @@ dt {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
margin-top: 8px;
|
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) {
|
@media (max-width: 991px) {
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type PendingSelectionAction = {
|
|||||||
operation: 'create' | 'reselect';
|
operation: 'create' | 'reselect';
|
||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
personality?: string;
|
personality?: string;
|
||||||
|
iconId?: string;
|
||||||
clientRequestId: string;
|
clientRequestId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const reservation = ref<Reservation | null>(null);
|
|||||||
const selectedUniqueName = ref<string | null>(null);
|
const selectedUniqueName = ref<string | null>(null);
|
||||||
const nations = ref<Nation[]>([]);
|
const nations = ref<Nation[]>([]);
|
||||||
const personality = ref('Random');
|
const personality = ref('Random');
|
||||||
|
const selectedIconId = ref('');
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -66,7 +68,8 @@ const readPendingAction = (): PendingSelectionAction | null => {
|
|||||||
if (
|
if (
|
||||||
(value.operation !== 'create' && value.operation !== 'reselect') ||
|
(value.operation !== 'create' && value.operation !== 'reselect') ||
|
||||||
typeof value.uniqueName !== 'string' ||
|
typeof value.uniqueName !== 'string' ||
|
||||||
typeof value.clientRequestId !== 'string'
|
typeof value.clientRequestId !== 'string' ||
|
||||||
|
(value.iconId !== undefined && typeof value.iconId !== 'string')
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -79,13 +82,15 @@ const readPendingAction = (): PendingSelectionAction | null => {
|
|||||||
const getPendingAction = (
|
const getPendingAction = (
|
||||||
operation: PendingSelectionAction['operation'],
|
operation: PendingSelectionAction['operation'],
|
||||||
uniqueName: string,
|
uniqueName: string,
|
||||||
requestedPersonality?: string
|
requestedPersonality?: string,
|
||||||
|
requestedIconId?: string
|
||||||
): PendingSelectionAction => {
|
): PendingSelectionAction => {
|
||||||
const current = readPendingAction();
|
const current = readPendingAction();
|
||||||
if (
|
if (
|
||||||
current?.operation === operation &&
|
current?.operation === operation &&
|
||||||
current.uniqueName === uniqueName &&
|
current.uniqueName === uniqueName &&
|
||||||
current.personality === requestedPersonality
|
current.personality === requestedPersonality &&
|
||||||
|
current.iconId === requestedIconId
|
||||||
) {
|
) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@@ -93,6 +98,7 @@ const getPendingAction = (
|
|||||||
operation,
|
operation,
|
||||||
uniqueName,
|
uniqueName,
|
||||||
...(requestedPersonality ? { personality: requestedPersonality } : {}),
|
...(requestedPersonality ? { personality: requestedPersonality } : {}),
|
||||||
|
...(requestedIconId ? { iconId: requestedIconId } : {}),
|
||||||
clientRequestId: crypto.randomUUID(),
|
clientRequestId: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
|
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
|
||||||
@@ -200,11 +206,12 @@ const createGeneral = async (): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
|
const pending = getPendingAction('create', candidate.uniqueName, personality.value, selectedIconId.value);
|
||||||
try {
|
try {
|
||||||
await trpc.join.selectPoolGeneral.mutate({
|
await trpc.join.selectPoolGeneral.mutate({
|
||||||
uniqueName: candidate.uniqueName,
|
uniqueName: candidate.uniqueName,
|
||||||
personality: personality.value,
|
personality: personality.value,
|
||||||
|
...(selectedIconId.value ? { iconId: selectedIconId.value } : {}),
|
||||||
clientRequestId: pending.clientRequestId,
|
clientRequestId: pending.clientRequestId,
|
||||||
});
|
});
|
||||||
clearPendingAction(pending);
|
clearPendingAction(pending);
|
||||||
@@ -432,6 +439,24 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="config?.user.icons.length">
|
||||||
|
<th class="legacy-bg1">전콘 선택</th>
|
||||||
|
<td class="pool-icon-choice">
|
||||||
|
<label>
|
||||||
|
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수 전콘
|
||||||
|
</label>
|
||||||
|
<label v-for="icon in config.user.icons" :key="icon.id">
|
||||||
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(icon)"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
alt="내 전용 아이콘"
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" class="join-guidance">
|
<td colspan="2" class="join-guidance">
|
||||||
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
|
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
|
|||||||
|
|
||||||
const zSessionToken = z.string().min(1);
|
const zSessionToken = z.string().min(1);
|
||||||
const MAX_ICON_BYTES = 50 * 1024;
|
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 ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||||
|
|
||||||
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
|
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
|
||||||
@@ -46,8 +49,12 @@ export const kstDayStart = (value: Date): Date => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
|
if (
|
||||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
|
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)}`;
|
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildLibraryIcon = (
|
||||||
|
ctx: GatewayApiContext,
|
||||||
|
icon: Awaited<ReturnType<GatewayApiContext['users']['listIcons']>>[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) =>
|
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
|
||||||
(await ctx.profileStatus.listLobbyProfiles({ userId }))
|
(await ctx.profileStatus.listLobbyProfiles({ userId }))
|
||||||
.filter(
|
.filter(
|
||||||
@@ -95,6 +114,7 @@ const publishIconFlush = async (
|
|||||||
export const accountRouter = router({
|
export const accountRouter = router({
|
||||||
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
||||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||||
|
const icons = await ctx.users.listIcons(user.id);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -103,6 +123,15 @@ export const accountRouter = router({
|
|||||||
oauthType: user.oauthType,
|
oauthType: user.oauthType,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
iconUrl: buildIconUrl(ctx, user),
|
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,
|
thirdPartyUse: user.thirdPartyUse,
|
||||||
deleteAfter: user.deleteAfter ?? null,
|
deleteAfter: user.deleteAfter ?? null,
|
||||||
};
|
};
|
||||||
@@ -184,39 +213,104 @@ export const accountRouter = router({
|
|||||||
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
|
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
|
||||||
await fs.mkdir(ctx.userIconDir, { recursive: true });
|
await fs.mkdir(ctx.userIconDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
|
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
|
||||||
let revision: string | null;
|
let stored;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!revision) {
|
if (!stored.ok) {
|
||||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
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({
|
throw new TRPCError({
|
||||||
code: 'TOO_MANY_REQUESTS',
|
code: 'TOO_MANY_REQUESTS',
|
||||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
message: '아이콘 업로드는 24시간에 한 번만 가능합니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
||||||
revision,
|
revision: stored.revision,
|
||||||
|
icon: buildLibraryIcon(ctx, stored.icon),
|
||||||
profiles,
|
profiles,
|
||||||
flushPublished,
|
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 }) => {
|
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
|
||||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
assertIconChangeAvailable(user, now);
|
assertIconChangeAvailable(user, now);
|
||||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
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) {
|
if (!revision) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'TOO_MANY_REQUESTS',
|
code: 'TOO_MANY_REQUESTS',
|
||||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
message: '아이콘 변경은 24시간에 한 번만 가능합니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
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 => {
|
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
|
||||||
const usersByName = new Map<string, UserRecord>();
|
const usersByName = new Map<string, UserRecord>();
|
||||||
const usersByOauthId = new Map<string, UserRecord>();
|
const usersByOauthId = new Map<string, UserRecord>();
|
||||||
const usersByEmail = new Map<string, UserRecord>();
|
const usersByEmail = new Map<string, UserRecord>();
|
||||||
|
const iconsById = new Map<string, UserIconRecord>();
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
async findById(id: string): Promise<UserRecord | null> {
|
async findById(id: string): Promise<UserRecord | null> {
|
||||||
@@ -165,14 +175,18 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
|||||||
imageServer: number,
|
imageServer: number,
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean
|
consumeDailyQuota: boolean,
|
||||||
|
allowCutoffEquality = false
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
for (const user of usersByName.values()) {
|
for (const user of usersByName.values()) {
|
||||||
if (user.id !== userId) {
|
if (user.id !== userId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) {
|
if (user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
||||||
return null;
|
const previousUpdate = new Date(user.iconUpdatedAt);
|
||||||
|
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const previousRevision = Math.max(
|
const previousRevision = Math.max(
|
||||||
new Date(user.createdAt).getTime(),
|
new Date(user.createdAt).getTime(),
|
||||||
@@ -191,6 +205,67 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
|||||||
}
|
}
|
||||||
throw new Error('User not found.');
|
throw new Error('User not found.');
|
||||||
},
|
},
|
||||||
|
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
|
||||||
|
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<string | null> {
|
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||||
for (const user of usersByName.values()) {
|
for (const user of usersByName.values()) {
|
||||||
if (user.id !== userId) {
|
if (user.id !== userId) {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
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[] => {
|
const readStringArray = (value: unknown): string[] => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
@@ -46,6 +53,7 @@ const mapUser = (row: {
|
|||||||
iconUpdatedAt: Date | null;
|
iconUpdatedAt: Date | null;
|
||||||
iconRevision: Date | null;
|
iconRevision: Date | null;
|
||||||
profileIconResetAt: Date | null;
|
profileIconResetAt: Date | null;
|
||||||
|
iconRetiredAt: Date | null;
|
||||||
thirdPartyUse: boolean;
|
thirdPartyUse: boolean;
|
||||||
termsAcceptedAt: Date | null;
|
termsAcceptedAt: Date | null;
|
||||||
privacyAcceptedAt: Date | null;
|
privacyAcceptedAt: Date | null;
|
||||||
@@ -69,6 +77,7 @@ const mapUser = (row: {
|
|||||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||||
iconRevision: row.iconRevision?.toISOString(),
|
iconRevision: row.iconRevision?.toISOString(),
|
||||||
profileIconResetAt: row.profileIconResetAt?.toISOString(),
|
profileIconResetAt: row.profileIconResetAt?.toISOString(),
|
||||||
|
iconRetiredAt: row.iconRetiredAt?.toISOString(),
|
||||||
thirdPartyUse: row.thirdPartyUse,
|
thirdPartyUse: row.thirdPartyUse,
|
||||||
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
||||||
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
||||||
@@ -82,6 +91,22 @@ const mapUser = (row: {
|
|||||||
legacyGrade: readLegacyGrade(row.legacyData),
|
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 = (
|
export const createPostgresUserRepository = (
|
||||||
prisma: GatewayPrismaClient,
|
prisma: GatewayPrismaClient,
|
||||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||||
@@ -242,7 +267,8 @@ export const createPostgresUserRepository = (
|
|||||||
imageServer: number,
|
imageServer: number,
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean
|
consumeDailyQuota: boolean,
|
||||||
|
allowCutoffEquality = false
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||||
UPDATE "app_user"
|
UPDATE "app_user"
|
||||||
@@ -262,11 +288,119 @@ export const createPostgresUserRepository = (
|
|||||||
"picture" = 'default.jpg'
|
"picture" = 'default.jpg'
|
||||||
OR "icon_updated_at" IS NULL
|
OR "icon_updated_at" IS NULL
|
||||||
OR "icon_updated_at" < ${dayStart}
|
OR "icon_updated_at" < ${dayStart}
|
||||||
|
OR (${allowCutoffEquality} AND "icon_updated_at" = ${dayStart})
|
||||||
)
|
)
|
||||||
RETURNING "icon_revision" AS "iconRevision"
|
RETURNING "icon_revision" AS "iconRevision"
|
||||||
`);
|
`);
|
||||||
return rows[0]?.iconRevision.toISOString() ?? null;
|
return rows[0]?.iconRevision.toISOString() ?? null;
|
||||||
},
|
},
|
||||||
|
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
|
||||||
|
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<Array<{ createdAt: Date; iconRevision: Date | null }>>(
|
||||||
|
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<string | null> {
|
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const rows = await tx.$queryRaw<
|
const rows = await tx.$queryRaw<
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface UserRecord {
|
|||||||
iconUpdatedAt?: string;
|
iconUpdatedAt?: string;
|
||||||
iconRevision?: string;
|
iconRevision?: string;
|
||||||
profileIconResetAt?: string;
|
profileIconResetAt?: string;
|
||||||
|
iconRetiredAt?: string;
|
||||||
thirdPartyUse: boolean;
|
thirdPartyUse: boolean;
|
||||||
termsAcceptedAt?: string;
|
termsAcceptedAt?: string;
|
||||||
privacyAcceptedAt?: string;
|
privacyAcceptedAt?: string;
|
||||||
@@ -26,6 +27,22 @@ export interface UserRecord {
|
|||||||
legacyGrade?: number;
|
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 {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -110,8 +127,20 @@ export interface UserRepository {
|
|||||||
imageServer: number,
|
imageServer: number,
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean
|
consumeDailyQuota: boolean,
|
||||||
|
allowCutoffEquality?: boolean
|
||||||
): Promise<string | null>;
|
): Promise<string | null>;
|
||||||
|
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
|
||||||
|
addIconForWindow(
|
||||||
|
userId: string,
|
||||||
|
picture: string,
|
||||||
|
imageServer: number,
|
||||||
|
now: Date,
|
||||||
|
uploadCutoff: Date,
|
||||||
|
maxActive: number
|
||||||
|
): Promise<AddUserIconResult>;
|
||||||
|
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
|
||||||
|
retireIconForWindow(userId: string, iconId: string, now: Date, retireCutoff: Date): Promise<RetireUserIconResult>;
|
||||||
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
|
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
|
||||||
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
||||||
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
||||||
|
|||||||
@@ -658,6 +658,7 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||||
|
const accountIcons = await ctx.users.listIcons(user.id);
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: gameSession.profile,
|
profile: gameSession.profile,
|
||||||
@@ -676,6 +677,12 @@ export const appRouter = router({
|
|||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
legacyMemberNo: user.legacyMemberNo,
|
legacyMemberNo: user.legacyMemberNo,
|
||||||
|
icons: accountIcons.map((icon) => ({
|
||||||
|
id: icon.id,
|
||||||
|
picture: icon.picture,
|
||||||
|
imageServer: icon.imageServer,
|
||||||
|
createdAt: icon.createdAt,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
sanctions: user.sanctions,
|
sanctions: user.sanctions,
|
||||||
identity: {
|
identity: {
|
||||||
|
|||||||
@@ -154,4 +154,48 @@ integration('account icon daily PostgreSQL CAS', () => {
|
|||||||
sanctions: { warningCount: 1 },
|
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() })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ describe('account self service', () => {
|
|||||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
|
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 iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
|
||||||
const png = await sharp({
|
const png = await sharp({
|
||||||
create: {
|
create: {
|
||||||
@@ -809,11 +809,17 @@ describe('account self service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
|
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
|
||||||
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||||
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
|
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({
|
const changed = await caller.account.changeIcon({
|
||||||
sessionToken: session.sessionToken,
|
sessionToken: nextSession.sessionToken,
|
||||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||||
});
|
});
|
||||||
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,8 @@ const activeProfiles = [
|
|||||||
|
|
||||||
const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||||
let deleteIconCount = 0;
|
let deleteIconCount = 0;
|
||||||
|
let preferredIconCount = 0;
|
||||||
|
let retireIconCount = 0;
|
||||||
let hweAdjustCount = 0;
|
let hweAdjustCount = 0;
|
||||||
const operations = new Map<string, string[]>([
|
const operations = new Map<string, string[]>([
|
||||||
['che:903', []],
|
['che:903', []],
|
||||||
@@ -83,6 +85,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
oauthType: 'NONE',
|
oauthType: 'NONE',
|
||||||
createdAt: '2026-07-30T00:00:00.000Z',
|
createdAt: '2026-07-30T00:00:00.000Z',
|
||||||
iconUrl: '/gateway/api/user-icons/old.png',
|
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,
|
thirdPartyUse: false,
|
||||||
deleteAfter: null,
|
deleteAfter: null,
|
||||||
});
|
});
|
||||||
@@ -96,6 +120,20 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
flushPublished: true,
|
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') {
|
if (operation === 'account.deleteIcon') {
|
||||||
deleteIconCount += 1;
|
deleteIconCount += 1;
|
||||||
return response({
|
return response({
|
||||||
@@ -182,9 +220,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
return {
|
return {
|
||||||
operations,
|
operations,
|
||||||
deleteIconCount: () => deleteIconCount,
|
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<void> => {
|
const uploadIcon = async (page: Page): Promise<void> => {
|
||||||
await page.locator('input[type="file"]').setInputFiles({
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
name: 'new-icon.png',
|
name: 'new-icon.png',
|
||||||
|
|||||||
@@ -303,10 +303,36 @@ const changeIcon = async (event?: Event): Promise<void> => {
|
|||||||
successMessage.value = result.flushPublished
|
successMessage.value = result.flushPublished
|
||||||
? '전용 아이콘을 변경했습니다.'
|
? '전용 아이콘을 변경했습니다.'
|
||||||
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||||
|
await loadAccount();
|
||||||
openIconServerModal(result.profiles, returnFocus);
|
openIconServerModal(result.profiles, returnFocus);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPreferredIcon = async (iconId: string): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
const deleteIcon = async (event?: Event): Promise<void> => {
|
||||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
if (!window.confirm('아이콘을 제거할까요?')) return;
|
if (!window.confirm('아이콘을 제거할까요?')) return;
|
||||||
@@ -497,6 +523,41 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="legacy-bg1">전콘<br />목록</th>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="account-icon-library">
|
||||||
|
<div v-for="icon in account.icons" :key="icon.id" class="account-icon-card">
|
||||||
|
<img :src="icon.url" width="64" height="64" alt="전용 아이콘" />
|
||||||
|
<span v-if="icon.picture === account.preferredPicture" class="preferred-label"
|
||||||
|
>대표</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="skin-button compact"
|
||||||
|
type="button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="setPreferredIcon(icon.id)"
|
||||||
|
>
|
||||||
|
대표로 설정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="skin-button compact"
|
||||||
|
type="button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="retireIcon(icon.id)"
|
||||||
|
>
|
||||||
|
목록에서 내리기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
|
||||||
|
</div>
|
||||||
|
<p class="icon-policy">
|
||||||
|
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 ·
|
||||||
|
목록에서 내리기는 7일에 1회
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody v-else>
|
<tbody v-else>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1043,6 +1104,29 @@ onBeforeUnmount(() => {
|
|||||||
outline: 0;
|
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) {
|
@media (max-width: 600px) {
|
||||||
#account-container {
|
#account-container {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export interface GatewayUserInfo {
|
|||||||
canUseGeneralPicture?: boolean;
|
canUseGeneralPicture?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
legacyMemberNo?: number;
|
legacyMemberNo?: number;
|
||||||
|
icons?: GatewayUserIconInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayUserIconInfo {
|
||||||
|
id: string;
|
||||||
|
picture: string;
|
||||||
|
imageServer: number;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSessionTokenPayload {
|
export interface GameSessionTokenPayload {
|
||||||
@@ -98,6 +106,20 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
|
|||||||
(user.profileIconResetAt !== undefined &&
|
(user.profileIconResetAt !== undefined &&
|
||||||
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
|
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
|
||||||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
|
(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))
|
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export type TurnDaemonCommand =
|
|||||||
picture: string;
|
picture: string;
|
||||||
imageServer: number;
|
imageServer: number;
|
||||||
iconRevision: string;
|
iconRevision: string;
|
||||||
|
enforceCooldown?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'joinCreateGeneral';
|
type: 'joinCreateGeneral';
|
||||||
@@ -254,6 +255,9 @@ export type TurnDaemonCommand =
|
|||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
personality: string;
|
personality: string;
|
||||||
seedOwnerIdentity: string | number;
|
seedOwnerIdentity: string | number;
|
||||||
|
ownerPicture?: string;
|
||||||
|
ownerImageServer?: number;
|
||||||
|
ownerIconRevision?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'selectPoolReselect';
|
type: 'selectPoolReselect';
|
||||||
@@ -534,8 +538,9 @@ export type TurnDaemonCommandResult =
|
|||||||
| {
|
| {
|
||||||
type: 'adjustGeneralIcon';
|
type: 'adjustGeneralIcon';
|
||||||
ok: false;
|
ok: false;
|
||||||
code: 'CONFLICT' | 'PRECONDITION_FAILED';
|
code: 'CONFLICT' | 'PRECONDITION_FAILED' | 'TOO_MANY_REQUESTS';
|
||||||
reason: string;
|
reason: string;
|
||||||
|
availableAt?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'joinCreateGeneral';
|
type: 'joinCreateGeneral';
|
||||||
|
|||||||
+29
@@ -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;
|
||||||
@@ -59,36 +59,51 @@ enum GatewaySourceMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AppUser {
|
model AppUser {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
loginId String @unique @map("login_id")
|
loginId String @unique @map("login_id")
|
||||||
displayName String @unique @map("display_name")
|
displayName String @unique @map("display_name")
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
passwordSalt String @map("password_salt")
|
passwordSalt String @map("password_salt")
|
||||||
roles Json @default(dbgenerated("'[]'::jsonb"))
|
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||||
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
oauthType OAuthType @default(NONE) @map("oauth_type")
|
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||||
oauthId String? @unique @map("oauth_id")
|
oauthId String? @unique @map("oauth_id")
|
||||||
email String? @unique
|
email String? @unique
|
||||||
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
|
||||||
picture String @default("default.jpg")
|
picture String @default("default.jpg")
|
||||||
imageServer Int @default(0) @map("image_server")
|
imageServer Int @default(0) @map("image_server")
|
||||||
iconUpdatedAt DateTime? @map("icon_updated_at")
|
iconUpdatedAt DateTime? @map("icon_updated_at")
|
||||||
iconRevision DateTime? @map("icon_revision")
|
iconRevision DateTime? @map("icon_revision")
|
||||||
profileIconResetAt DateTime? @map("profile_icon_reset_at")
|
profileIconResetAt DateTime? @map("profile_icon_reset_at")
|
||||||
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
iconRetiredAt DateTime? @map("icon_retired_at")
|
||||||
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
||||||
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
||||||
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
|
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
||||||
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
|
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
|
||||||
deleteAfter DateTime? @map("delete_after")
|
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
deleteAfter DateTime? @map("delete_after")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
|
||||||
|
icons UserIcon[]
|
||||||
|
|
||||||
@@map("app_user")
|
@@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 {
|
model LegacyMemberLog {
|
||||||
id BigInt @id
|
id BigInt @id
|
||||||
memberNo Int @map("member_no")
|
memberNo Int @map("member_no")
|
||||||
|
|||||||
Reference in New Issue
Block a user