feat: 관리자 계정 식별자와 카카오 영구 교체 지원

관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
This commit is contained in:
2026-08-24 23:25:49 +00:00
parent e8dc7a65aa
commit 10cd1ed6d4
34 changed files with 1496 additions and 143 deletions
@@ -355,6 +355,16 @@ const zAdjustGeneralIcon = z
})
.strict();
const zAdjustGeneralIdentity = z
.object({
type: z.literal('adjustGeneralIdentity'),
requestId: z.string().min(1).optional(),
userId: z.string().min(1),
displayName: z.string().min(1),
identityRevision: z.string().refine(isCanonicalIsoTimestamp),
})
.strict();
const zJoinCreateGeneral = z
.object({
type: z.literal('joinCreateGeneral'),
@@ -745,6 +755,14 @@ const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (enve
return { ...command, requestId: envelope.requestId };
};
const normalizeAdjustGeneralIdentity: CommandNormalizer<'adjustGeneralIdentity'> = (envelope) => {
const command = parseWith(zAdjustGeneralIdentity, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => {
const command = parseWith(zJoinCreateGeneral, envelope.command);
if (!command) {
@@ -861,6 +879,7 @@ const normalizers: CommandNormalizerMap = {
patchGeneral: normalizePatchGeneral,
inheritanceAction: normalizeInheritanceAction,
adjustGeneralIcon: normalizeAdjustGeneralIcon,
adjustGeneralIdentity: normalizeAdjustGeneralIdentity,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
selectPoolReserve: normalizeSelectPoolReserve,
@@ -242,6 +242,7 @@ const resolveCommandAcceptedAt = async (
| 'selectPoolCreate'
| 'selectPoolReselect'
| 'adjustGeneralIcon'
| 'adjustGeneralIdentity'
| 'inheritanceAction'
| 'setNationSetting'
| 'setNpcPolicy';
@@ -989,6 +990,62 @@ async function handleAdjustGeneralIcon(
};
}
async function handleAdjustGeneralIdentity(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIdentity' }>
): Promise<TurnDaemonCommandResult> {
await resolveCommandAcceptedAt(requireCommandDatabase(ctx), command);
const general = ctx.world
.listGenerals()
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
if (!general) {
return { type: 'adjustGeneralIdentity', ok: true, generalId: null, updated: false };
}
const currentRevision = general.meta.ownerIdentityRevision;
if (currentRevision !== undefined) {
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
return {
type: 'adjustGeneralIdentity',
ok: false,
code: 'PRECONDITION_FAILED',
reason: '장수의 계정 이름 revision이 올바르지 않습니다.',
};
}
const currentTime = new Date(currentRevision).getTime();
const nextTime = new Date(command.identityRevision).getTime();
if (nextTime < currentTime) {
return {
type: 'adjustGeneralIdentity',
ok: false,
code: 'CONFLICT',
reason: '더 최신 계정 이름이 이미 적용되었습니다.',
};
}
if (nextTime === currentTime) {
const currentName = general.meta.ownerDisplayName ?? general.meta.ownerName ?? general.meta.owner_name;
if (currentName === command.displayName) {
return { type: 'adjustGeneralIdentity', ok: true, generalId: general.id, updated: false };
}
return {
type: 'adjustGeneralIdentity',
ok: false,
code: 'CONFLICT',
reason: '같은 revision에 다른 계정 이름이 요청되었습니다.',
};
}
}
ctx.world.updateGeneral(general.id, {
meta: {
...general.meta,
ownerDisplayName: command.displayName,
ownerName: command.displayName,
owner_name: command.displayName,
ownerIdentityRevision: command.identityRevision,
},
});
return { type: 'adjustGeneralIdentity', ok: true, generalId: general.id, updated: true };
}
async function handleShiftSchedule(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>
@@ -3120,6 +3177,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
adjustGeneralIdentity: (command) =>
handleAdjustGeneralIdentity(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIdentity' }>),
joinCreateGeneral: (command) =>
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
npcPossessGeneral: (command) =>
@@ -100,6 +100,18 @@ const buildCommandDb = (actorUserId = 'user-1', acceptedAt = new Date('2026-07-3
},
}) as unknown as GamePrisma.TransactionClient;
const buildIdentityCommandDb = (actorUserId = 'user-1') =>
({
inputEvent: {
findUnique: vi.fn(async () => ({
createdAt: new Date(revision),
actorUserId,
target: 'ENGINE',
eventType: 'adjustGeneralIdentity',
})),
},
}) as unknown as GamePrisma.TransactionClient;
const command = (
overrides: Partial<{
requestId: string;
@@ -220,3 +232,43 @@ describe('adjustGeneralIcon ENGINE command', () => {
});
});
});
describe('adjustGeneralIdentity ENGINE command', () => {
it('updates only the current human general owner-name projection', async () => {
const world = buildWorld([
buildGeneral({ meta: { killturn: 24, ownerName: '이전닉네임' } }),
buildGeneral({ id: 2, npcState: 1, meta: { killturn: 24, ownerName: '이전닉네임' } }),
]);
const handler = createTurnDaemonCommandHandler({ world });
const identityCommand = {
type: 'adjustGeneralIdentity' as const,
requestId: `general:adjustIdentity:user-1:${revision}`,
userId: 'user-1',
displayName: '새닉네임',
identityRevision: revision,
};
await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toEqual({
type: 'adjustGeneralIdentity',
ok: true,
generalId: 1,
updated: true,
});
expect(world.getGeneralById(1)?.meta).toMatchObject({
killturn: 24,
ownerDisplayName: '새닉네임',
ownerName: '새닉네임',
owner_name: '새닉네임',
ownerIdentityRevision: revision,
});
expect(world.getGeneralById(2)?.meta).toMatchObject({ ownerName: '이전닉네임' });
await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toMatchObject({
ok: true,
updated: false,
});
await expect(
handler.handle({ ...identityCommand, displayName: '충돌닉네임' }, { db: buildIdentityCommandDb() })
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
});
});