feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+79 -10
View File
@@ -10,6 +10,7 @@ import type { GatewayApiContext } from '../context.js';
import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
const zSessionToken = z.string().min(1);
const MAX_ICON_BYTES = 50 * 1024;
@@ -37,13 +38,15 @@ const decodeImage = (input: string): Buffer => {
return buffer;
};
const sameUtcDate = (left: Date, right: Date): boolean =>
left.getUTCFullYear() === right.getUTCFullYear() &&
left.getUTCMonth() === right.getUTCMonth() &&
left.getUTCDate() === right.getUTCDate();
const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000;
export const kstDayStart = (value: Date): Date => {
const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS);
return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS);
};
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
}
};
@@ -59,8 +62,34 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
};
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
if (user.imageServer !== 1 || user.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`;
const icon = resolveEffectiveAccountIcon(user);
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
};
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
(await ctx.profileStatus.listLobbyProfiles({ userId }))
.filter(
(profile) => (profile.status === 'RUNNING' || profile.status === 'PREOPEN') && profile.runtime.apiRunning
)
.map(({ profileName, profile, apiPort, korName }) => ({
profileName,
profile,
apiPort,
korName,
}));
const publishIconFlush = async (
ctx: GatewayApiContext,
userId: string,
reason: 'account-icon-changed' | 'account-icon-deleted'
): Promise<boolean> => {
try {
await ctx.flushPublisher.publishUserFlush(userId, reason);
return true;
} catch {
return false;
}
};
export const accountRouter = router({
@@ -136,6 +165,7 @@ export const accountRouter = router({
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
@@ -154,17 +184,56 @@ export const accountRouter = router({
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
await fs.mkdir(ctx.userIconDir, { recursive: true });
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
await ctx.users.updateIcon(user.id, filename, 1, now);
let revision: string | null;
try {
revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true);
} catch (error) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
throw error;
}
if (!revision) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
});
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
revision,
profiles,
flushPublished,
};
}),
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
assertIconChangeAvailable(user, now);
await ctx.users.updateIcon(user.id, 'default.jpg', 0, now);
return { ok: true, iconUrl: null };
const profiles = await listIconSyncProfiles(ctx, user.id);
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false);
if (!revision) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
});
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
return {
ok: true,
iconUrl: null,
revision,
profiles,
flushPublished,
};
}),
prepareIconSync: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return {
iconUrl: buildIconUrl(ctx, user),
projection: resolveEffectiveAccountIcon(user),
profiles: await listIconSyncProfiles(ctx, user.id),
};
}),
});