fix: 유저 장수 preset 아이콘 적용 차단

사람 장수는 활성 전용 아이콘 ID를 명시한 경우에만 사용자 아이콘을 사용한다. 일반 생성, 선택 pool, Gateway 서버 동기화의 대표·후보 preset fallback을 제거하고 회귀 검증을 추가한다.
This commit is contained in:
2026-08-24 08:38:50 +00:00
parent 379312470e
commit 833ade670c
13 changed files with 227 additions and 76 deletions
+37 -9
View File
@@ -693,7 +693,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
export const generalRouter = router({
adjustIcon: engineAuthedProcedure
.input(
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
z
.object({
iconId: z.string().uuid().optional(),
resetToDefault: z.literal(true).optional(),
clientRequestId: z.string().uuid().optional(),
})
.optional()
)
.mutation(({ ctx, input }) => {
const userId = ctx.auth?.user.id;
@@ -701,19 +707,41 @@ export const generalRouter = router({
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)) {
const resetToDefault = input?.resetToDefault === true;
if (resetToDefault && input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
}
if (!resetToDefault && !input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
}
if (
resetToDefault &&
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
) {
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
}
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
if (!iconRevision) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
}
const projection = resetToDefault
? {
picture: 'default.jpg',
imageServer: 0,
revision: iconRevision,
}
: {
picture: selected!.picture,
imageServer: selected!.imageServer,
revision: iconRevision,
};
return adjustAccountIconForUser(
ctx,
userId,
selected
? {
picture: selected.picture,
imageServer: selected.imageServer,
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
}
: undefined,
projection,
true,
input?.clientRequestId ?? ctx.requestId
);
+7 -6
View File
@@ -14,7 +14,6 @@ import {
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
@@ -515,15 +514,17 @@ export const joinRouter = router({
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const accountIcon = input.pic
? selectedIcon
// 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
// 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
// iconId 없는 fallback으로 사용하지 않는다.
const accountIcon =
input.pic && selectedIcon
? {
picture: selectedIcon.picture,
imageServer: selectedIcon.imageServer,
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
}
: await loadAuthoritativeAccountIcon(ctx, userId)
: null;
: null;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral',
@@ -535,7 +536,7 @@ export const joinRouter = router({
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
pic: input.pic,
pic: accountIcon !== null,
character: input.character,
profileId: ctx.profile.id,
...(accountIcon
+3 -5
View File
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
export const adjustAccountIconForUser = async (
ctx: GameApiContext,
userId: string,
selected?: AccountIconProjection,
selected: AccountIconProjection,
enforceCooldown = true,
requestKey?: string
): Promise<{
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
generalId: number | null;
updated: boolean;
}> => {
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
const requestId = selected
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
: `general:adjustIcon:${userId}:${projection.revision}`;
const projection = selected;
const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
try {
const result = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralIcon',
+50 -39
View File
@@ -388,45 +388,56 @@ describe('appRouter', () => {
});
});
it('applies the current Gateway database icon instead of stale token claims', async () => {
it('rejects icon adjustment without an explicitly selected active icon', async () => {
const transport = new InMemoryTurnDaemonTransport();
const currentAccountIcon = {
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
};
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.000Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
accountIconGet,
})
);
await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands).toHaveLength(0);
});
it('allows an explicit default reset only when the signed account projection is default', async () => {
const transport = new InMemoryTurnDaemonTransport();
const auth = buildAuth();
const revision = '2026-07-31T09:00:00.000Z';
auth.user.picture = 'default.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = revision;
const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`;
transport.setCommandResult(requestId, {
type: 'adjustGeneralIcon',
ok: true,
generalId: 1,
updated: true,
});
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
currentAccountIcon,
})
);
const caller = appRouter.createCaller(buildContext({ auth, transport }));
await expect(caller.general.adjustIcon()).resolves.toEqual({
await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
ok: true,
generalId: 1,
updated: true,
});
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
expect(transport.commands.at(-1)?.command).toMatchObject({
requestId,
userId: auth.user.id,
picture: 'latest.png',
imageServer: 1,
iconRevision: currentAccountIcon.revision,
enforceCooldown: true,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
});
});
@@ -461,13 +472,13 @@ describe('appRouter', () => {
});
});
it('rejects icon adjustment without auth or a current Gateway account', async () => {
it('rejects icon adjustment without auth or a selected icon', async () => {
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
@@ -581,30 +592,30 @@ describe('appRouter', () => {
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
it('does not apply a shared Gateway representative when no active icon id was selected', async () => {
const transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
const requestId = `join-create:user-1:${clientRequestId}`;
const revision = '2026-07-31T09:00:00.001Z';
transport.setCommandResult(requestId, {
type: 'joinCreateGeneral',
ok: true,
generalId: 42,
});
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.001Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
auth,
transport,
currentAccountIcon: {
revision,
picture: 'latest.png',
imageServer: 1,
},
accountIconGet,
})
);
@@ -618,11 +629,11 @@ describe('appRouter', () => {
clientRequestId,
});
expect(transport.commands.at(-1)?.command).toMatchObject({
ownerPicture: 'latest.png',
ownerImageServer: 1,
ownerIconRevision: revision,
});
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
@@ -257,6 +257,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const initial = await db.general.findFirstOrThrow({ where: { userId } });
const initialRuntime = runtime!.world.getGeneralById(initial.id);
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp');
}
@@ -268,7 +269,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
id: initial.id,
userId,
name: initial.name,
imageServer: initial.imageServer,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: initial.leadership,
strength: initial.strength,
@@ -380,15 +382,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
intel: target.intel,
personalCode: initial.personalCode,
specialCode: target.specialDomestic,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
});
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
id: initial.id,
userId,
name: target.generalName,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: target.leadership,
strength: target.strength,
+25 -7
View File
@@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식';
export const resolveSelectionPoolUserIcon = (options: {
showImgLevel: number;
ownerPicture?: string;
ownerImageServer?: number;
}): { picture: string; imageServer: number } => {
const useOwnerPicture =
options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
return useOwnerPicture
? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 }
: { picture: 'default.jpg', imageServer: 0 };
};
const zCandidateInfo = z.object({
uniqueName: z.string().min(1),
generalName: z.string().min(1),
@@ -755,11 +767,14 @@ export const createGeneralFromSelectionPool = async (options: {
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
const showImgLevel = asNumber(config.showImgLevel, 0);
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;
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
const { picture, imageServer } = resolveSelectionPoolUserIcon({
showImgLevel: asNumber(config.showImgLevel, 0),
ownerPicture: options.ownerPicture,
ownerImageServer: options.ownerImageServer,
});
const useOwnerPicture = picture !== 'default.jpg';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const defaultSpecialDomestic =
@@ -1022,6 +1037,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
now
),
};
const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 });
const updated = world.updateGeneral(general.id, {
name: info.generalName,
stats: centennialGrowth?.stats ?? {
@@ -1035,8 +1051,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? general.role.specialWar,
},
picture: info.picture,
imageServer: info.imgsvr,
// 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을
// 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다.
picture: reselectionIcon.picture,
imageServer: reselectionIcon.imageServer,
meta: updatedMeta,
});
if (!updated) {
@@ -4,7 +4,7 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js';
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow {
@@ -171,6 +171,27 @@ const worldState = {
};
describe('selection-pool reservation command state', () => {
it('uses only an explicitly selected owner icon for a human general', () => {
expect(resolveSelectionPoolUserIcon({ showImgLevel: 3 })).toEqual({
picture: 'default.jpg',
imageServer: 0,
});
expect(
resolveSelectionPoolUserIcon({
showImgLevel: 3,
ownerPicture: 'uploaded/user.png',
ownerImageServer: 1,
})
).toEqual({ picture: 'uploaded/user.png', imageServer: 1 });
expect(
resolveSelectionPoolUserIcon({
showImgLevel: 0,
ownerPicture: 'uploaded/user.png',
ownerImageServer: 1,
})
).toEqual({ picture: 'default.jpg', imageServer: 0 });
});
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
const rows = buildRows();
const world = buildWorld(rows);
+34
View File
@@ -1981,6 +1981,40 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId });
});
test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청에 전달하지 않는다', async ({ page }) => {
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: '장수/유비.jpg',
icons: [],
},
personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }],
nations: [],
selectionPool: { enabled: false },
npcPossession: { enabled: false },
inherit: null,
},
};
await install(page, state);
await page.goto('join');
await expect(page.getByRole('radiogroup', { name: '전용 아이콘 선택' })).toHaveCount(0);
await page.getByRole('button', { name: '장수 생성', exact: true }).last().click();
await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1);
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: false });
expect(state.createGeneralInputs?.[0]).not.toHaveProperty('iconId');
});
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
@@ -429,6 +429,7 @@ test.describe('scenario 903 live selection pool', () => {
expect(created.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/);
expect(created).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' },
@@ -508,6 +509,12 @@ test.describe('scenario 903 live selection pool', () => {
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName);
await expect
.poll(async () => {
const general = await db.general.findUniqueOrThrow({ where: { id: created.id } });
return { picture: general.picture, imageServer: general.imageServer };
})
.toEqual({ picture: 'default.jpg', imageServer: 0 });
const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' },
+2 -1
View File
@@ -64,7 +64,7 @@ const form = ref<JoinForm>({
strength: 0,
intel: 0,
character: 'Random',
pic: true,
pic: false,
iconId: undefined,
inheritBonusStat: [0, 0, 0],
});
@@ -437,6 +437,7 @@ const loadConfig = async () => {
} else {
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
form.value.pic = form.value.iconId !== undefined;
applyBalancedStats();
}
} catch (err) {
@@ -429,7 +429,7 @@ onBeforeUnmount(() => {
<th class="legacy-bg1">전콘 선택</th>
<td class="pool-icon-choice">
<label>
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수
<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" />
@@ -40,6 +40,16 @@ const profileInputAt = (body: string, index: number): string | null => {
}
};
const adjustIconInputAt = (body: string, index: number): Record<string, unknown> => {
try {
const parsed = JSON.parse(body) as Record<string, Record<string, unknown> & { json?: Record<string, unknown> }>;
const input = parsed[String(index)];
return input?.json ?? input ?? {};
} catch {
return {};
}
};
type FixtureOptions = {
failHweAdjustOnce?: boolean;
delayHweAdjust?: boolean;
@@ -65,6 +75,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
let preferredIconCount = 0;
let retireIconCount = 0;
let hweAdjustCount = 0;
const adjustIconInputs: Array<Record<string, unknown>> = [];
const operations = new Map<string, string[]>([
['che:903', []],
['hwe:903', []],
@@ -199,7 +210,8 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
);
const results = [];
for (const operation of operationNames(route)) {
const body = route.request().postData() ?? '';
for (const [index, operation] of operationNames(route).entries()) {
if (operation === 'auth.exchangeGatewayToken') {
operations.get(profileName)?.push('exchangeGatewayToken');
results.push(
@@ -213,6 +225,11 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
}
if (operation === 'general.adjustIcon') {
operations.get(profileName)?.push('adjustIcon');
const input = adjustIconInputAt(body, index);
adjustIconInputs.push(input);
if (input.resetToDefault !== true) {
expect(input.iconId).toBe('3f804277-584f-4f44-b39c-9ecf40d1ed31');
}
if (profileName === 'hwe:903') {
hweAdjustCount += 1;
if (options.delayHweAdjust) {
@@ -242,6 +259,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
deleteIconCount: () => deleteIconCount,
preferredIconCount: () => preferredIconCount,
retireIconCount: () => retireIconCount,
adjustIconInputs: () => adjustIconInputs,
};
};
@@ -558,6 +576,9 @@ test('uses the Ref delete confirmation and opens the modal only after acceptance
.toBe('none');
await page.keyboard.press('Escape');
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
await apply.click();
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
expect(fixture.adjustIconInputs().at(-1)).toEqual({ resetToDefault: true });
});
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
+10 -1
View File
@@ -442,7 +442,16 @@ const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void>
gatewayToken: issued.gameToken,
});
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
await gameTrpc.general.adjustIcon.mutate();
if (!account.value?.iconUrl) {
await gameTrpc.general.adjustIcon.mutate({ resetToDefault: true });
row.state = 'success';
return;
}
const selectedIconId = account.value?.icons.find(
(icon) => icon.picture === account.value?.preferredPicture
)?.id;
if (!selectedIconId) throw new Error('적용할 활성 전용 아이콘을 찾을 수 없습니다.');
await gameTrpc.general.adjustIcon.mutate({ iconId: selectedIconId });
row.state = 'success';
} catch (error) {
row.state = 'error';