feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
|||||||
kakaoGraceStartedAt: now.toISOString(),
|
kakaoGraceStartedAt: now.toISOString(),
|
||||||
passwordSalt: password.salt,
|
passwordSalt: password.salt,
|
||||||
passwordHash: password.hash,
|
passwordHash: password.hash,
|
||||||
|
passwordResetRequired: false,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
usersByName.set(input.username, user);
|
usersByName.set(input.username, user);
|
||||||
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
|||||||
const upgraded = await hasher.hash(password);
|
const upgraded = await hasher.hash(password);
|
||||||
user.passwordSalt = upgraded.salt;
|
user.passwordSalt = upgraded.salt;
|
||||||
user.passwordHash = upgraded.hash;
|
user.passwordHash = upgraded.hash;
|
||||||
|
user.passwordResetRequired = false;
|
||||||
}
|
}
|
||||||
return verified.ok;
|
return verified.ok;
|
||||||
},
|
},
|
||||||
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
|||||||
const next = await hasher.hash(password);
|
const next = await hasher.hash(password);
|
||||||
user.passwordSalt = next.salt;
|
user.passwordSalt = next.salt;
|
||||||
user.passwordHash = next.hash;
|
user.passwordHash = next.hash;
|
||||||
|
user.passwordResetRequired = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
|
|||||||
grants: readonly SpecialAccountAccessGrantRecord[],
|
grants: readonly SpecialAccountAccessGrantRecord[],
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
): boolean =>
|
): boolean =>
|
||||||
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
|
grants.some(
|
||||||
|
(grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())
|
||||||
|
);
|
||||||
|
|
||||||
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
||||||
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
||||||
@@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: {
|
|||||||
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
||||||
const expiresAt = active.some((grant) => !grant.expiresAt)
|
const expiresAt = active.some((grant) => !grant.expiresAt)
|
||||||
? null
|
? null
|
||||||
: active
|
: (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
|
||||||
.map((grant) => grant.expiresAt!)
|
|
||||||
.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
|
||||||
return {
|
return {
|
||||||
kind: selected.kind,
|
kind: selected.kind,
|
||||||
grantId: selected.id,
|
grantId: selected.id,
|
||||||
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
|||||||
'localAccountGeneralCreationGraceDays',
|
'localAccountGeneralCreationGraceDays',
|
||||||
generalCreationDefault
|
generalCreationDefault
|
||||||
);
|
);
|
||||||
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
|
const kakaoVerified =
|
||||||
|
options.user.oauthType === 'KAKAO' &&
|
||||||
|
Boolean(options.user.oauthId?.trim()) &&
|
||||||
|
Boolean(options.user.kakaoVerifiedAt);
|
||||||
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
const specialAccess = resolveSpecialAccess({
|
const specialAccess = resolveSpecialAccess({
|
||||||
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
|||||||
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
|
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
|
||||||
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
||||||
const canCreateGeneral =
|
const canCreateGeneral =
|
||||||
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
|
kakaoVerified ||
|
||||||
|
specialAccess?.allowsGeneralCreation === true ||
|
||||||
|
(accessAllowed && now < generalCreationEndsAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
|
|||||||
export interface OAuthSession {
|
export interface OAuthSession {
|
||||||
id: string;
|
id: string;
|
||||||
mode: OAuthMode;
|
mode: OAuthMode;
|
||||||
intent?: 'register' | 'link_existing' | 'rejoin';
|
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
kakaoId: string;
|
kakaoId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -93,6 +93,14 @@ end
|
|||||||
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const consumeOnceScript = `
|
||||||
|
local raw = redis.call('GET', KEYS[1])
|
||||||
|
if raw then
|
||||||
|
redis.call('DEL', KEYS[1])
|
||||||
|
end
|
||||||
|
return raw
|
||||||
|
`;
|
||||||
|
|
||||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||||
private readonly client: RedisClientLike;
|
private readonly client: RedisClientLike;
|
||||||
private readonly prefix: string;
|
private readonly prefix: string;
|
||||||
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
|||||||
|
|
||||||
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||||
const key = this.sessionKey(sessionId);
|
const key = this.sessionKey(sessionId);
|
||||||
const raw = await this.client.get(key);
|
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await this.client.del(key);
|
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
|
||||||
return parseJson<OAuthSession>(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const mapUser = (row: {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
|
passwordResetRequired: boolean;
|
||||||
roles: GatewayPrisma.JsonValue;
|
roles: GatewayPrisma.JsonValue;
|
||||||
sanctions: GatewayPrisma.JsonValue;
|
sanctions: GatewayPrisma.JsonValue;
|
||||||
oauthType: 'NONE' | 'KAKAO';
|
oauthType: 'NONE' | 'KAKAO';
|
||||||
@@ -107,6 +108,7 @@ const mapUser = (row: {
|
|||||||
deleteAfter: row.deleteAfter?.toISOString(),
|
deleteAfter: row.deleteAfter?.toISOString(),
|
||||||
passwordHash: row.passwordHash,
|
passwordHash: row.passwordHash,
|
||||||
passwordSalt: row.passwordSalt,
|
passwordSalt: row.passwordSalt,
|
||||||
|
passwordResetRequired: row.passwordResetRequired,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||||
legacyGrade: readLegacyGrade(row.legacyData),
|
legacyGrade: readLegacyGrade(row.legacyData),
|
||||||
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
|
|||||||
displayName: input.displayName ?? input.username,
|
displayName: input.displayName ?? input.username,
|
||||||
passwordHash: password.hash,
|
passwordHash: password.hash,
|
||||||
passwordSalt: password.salt,
|
passwordSalt: password.salt,
|
||||||
|
passwordResetRequired: false,
|
||||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||||
oauthType,
|
oauthType,
|
||||||
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
|
|||||||
data: {
|
data: {
|
||||||
passwordHash: upgraded.hash,
|
passwordHash: upgraded.hash,
|
||||||
passwordSalt: upgraded.salt,
|
passwordSalt: upgraded.salt,
|
||||||
|
passwordResetRequired: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
user.passwordHash = upgraded.hash;
|
user.passwordHash = upgraded.hash;
|
||||||
user.passwordSalt = upgraded.salt;
|
user.passwordSalt = upgraded.salt;
|
||||||
|
user.passwordResetRequired = false;
|
||||||
}
|
}
|
||||||
return verified.ok;
|
return verified.ok;
|
||||||
},
|
},
|
||||||
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
|
|||||||
data: {
|
data: {
|
||||||
passwordHash: next.hash,
|
passwordHash: next.hash,
|
||||||
passwordSalt: next.salt,
|
passwordSalt: next.salt,
|
||||||
|
passwordResetRequired: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UserRecord {
|
|||||||
deleteAfter?: string;
|
deleteAfter?: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
passwordSalt: string;
|
passwordSalt: string;
|
||||||
|
passwordResetRequired: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
legacyMemberNo?: number;
|
legacyMemberNo?: number;
|
||||||
legacyGrade?: number;
|
legacyGrade?: number;
|
||||||
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
picture: user.picture,
|
picture: user.picture,
|
||||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
|
||||||
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,42 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verified'>(
|
||||||
|
ctx: GatewayApiContext,
|
||||||
|
user: UserRecord,
|
||||||
|
accessToken: string,
|
||||||
|
successStatus: T
|
||||||
|
) => {
|
||||||
|
if (!user.passwordResetRequired) {
|
||||||
|
return finishKakaoLogin(ctx, user, accessToken, successStatus);
|
||||||
|
}
|
||||||
|
if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const oauthInfo = user.oauthInfo ?? {};
|
||||||
|
const passwordSetup = await ctx.oauthSessions.createSession({
|
||||||
|
mode: successStatus === 'verified' ? 'verify' : 'login',
|
||||||
|
intent: 'password_setup',
|
||||||
|
targetUserId: user.id,
|
||||||
|
kakaoId: user.oauthId,
|
||||||
|
email: user.email,
|
||||||
|
accessToken,
|
||||||
|
refreshToken: oauthInfo.refreshToken,
|
||||||
|
accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(),
|
||||||
|
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'password_setup' as const,
|
||||||
|
oauthSessionId: passwordSetup.id,
|
||||||
|
email: passwordSetup.email,
|
||||||
|
successStatus,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: router({
|
health: router({
|
||||||
ping: procedure.query(() => ({
|
ping: procedure.query(() => ({
|
||||||
@@ -348,7 +384,7 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
|
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
|
||||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
|
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
|
||||||
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
|
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pending.mode === 'change_pw') {
|
if (pending.mode === 'change_pw') {
|
||||||
@@ -415,7 +451,7 @@ export const appRouter = router({
|
|||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
|
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
||||||
@@ -562,7 +598,70 @@ export const appRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
||||||
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
|
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
|
||||||
|
}),
|
||||||
|
kakaoSetPassword: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
oauthSessionId: z.string().uuid(),
|
||||||
|
credential: zPasswordEnvelope,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||||
|
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||||
|
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const user = await ctx.users.findById(oauthSession.targetUserId);
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
user.oauthType !== 'KAKAO' ||
|
||||||
|
user.oauthId !== oauthSession.kakaoId ||
|
||||||
|
user.email?.toLowerCase() !== oauthSession.email.toLowerCase() ||
|
||||||
|
!user.passwordResetRequired
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (user.deleteAfter) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' });
|
||||||
|
}
|
||||||
|
if (isLoginBanned(user.sanctions)) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' });
|
||||||
|
}
|
||||||
|
let verifiedProfile;
|
||||||
|
try {
|
||||||
|
verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken));
|
||||||
|
} catch (error) {
|
||||||
|
return throwKakaoVerificationError(error);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
verifiedProfile.kakaoId !== oauthSession.kakaoId ||
|
||||||
|
verifiedProfile.email !== oauthSession.email.toLowerCase()
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.users.updatePassword(user.id, password);
|
||||||
|
const refreshed = await ctx.users.findById(user.id);
|
||||||
|
if (!refreshed) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' });
|
||||||
|
}
|
||||||
|
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed');
|
||||||
|
return finishKakaoLogin(
|
||||||
|
ctx,
|
||||||
|
refreshed,
|
||||||
|
oauthSession.accessToken,
|
||||||
|
oauthSession.mode === 'verify' ? 'verified' : 'login'
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
register: procedure
|
register: procedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
||||||
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
|
const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
|
||||||
const emailOwner = await users.createUser({
|
const emailOwner = await users.createUser({
|
||||||
username: 'email-owner',
|
username: 'email-owner',
|
||||||
password: 'owner-password',
|
password: 'owner-password',
|
||||||
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
|
|||||||
info: {},
|
info: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
emailOwner.passwordResetRequired = true;
|
||||||
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
||||||
kakaoProfile.id = 'different-kakao-id';
|
kakaoProfile.id = 'different-kakao-id';
|
||||||
|
|
||||||
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
|
|||||||
oauthSessionId: recovery.oauthSessionId,
|
oauthSessionId: recovery.oauthSessionId,
|
||||||
action: 'link_existing',
|
action: 'link_existing',
|
||||||
});
|
});
|
||||||
expect(linked.status).toBe('otp');
|
expect(linked.status).toBe('password_setup');
|
||||||
|
if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||||
|
expect(sentTalkMessages).toHaveLength(0);
|
||||||
|
const passwordSet = await caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: linked.oauthSessionId,
|
||||||
|
credential: sealPassword('replacement-password'),
|
||||||
|
});
|
||||||
|
expect(passwordSet.status).toBe('otp');
|
||||||
expect(sentTalkMessages).toHaveLength(1);
|
expect(sentTalkMessages).toHaveLength(1);
|
||||||
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
||||||
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
||||||
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
|
|||||||
email: 'tester@example.com',
|
email: 'tester@example.com',
|
||||||
});
|
});
|
||||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
||||||
|
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
|
||||||
|
const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
|
||||||
|
kakaoId: 'imported-kakao-id',
|
||||||
|
kakaoEmail: 'imported@example.com',
|
||||||
|
});
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: 'imported-kakao-user',
|
||||||
|
password: 'legacy-password',
|
||||||
|
oauth: {
|
||||||
|
type: 'KAKAO',
|
||||||
|
id: kakaoProfile.id,
|
||||||
|
email: kakaoProfile.email,
|
||||||
|
info: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
user.passwordResetRequired = true;
|
||||||
|
const createSession = vi.spyOn(sessions, 'createSession');
|
||||||
|
|
||||||
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
|
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
|
expect(login).toMatchObject({
|
||||||
|
status: 'password_setup',
|
||||||
|
email: 'imported@example.com',
|
||||||
|
successStatus: 'login',
|
||||||
|
});
|
||||||
|
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||||
|
expect(login).not.toHaveProperty('sessionToken');
|
||||||
|
expect(createSession).not.toHaveBeenCalled();
|
||||||
|
expect(sentTalkMessages).toHaveLength(0);
|
||||||
|
|
||||||
|
const setup = await caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: login.oauthSessionId,
|
||||||
|
credential: sealPassword('new-imported-password'),
|
||||||
|
});
|
||||||
|
expect(setup.status).toBe('otp');
|
||||||
|
expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
|
||||||
|
expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
|
||||||
|
await expect(
|
||||||
|
caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: login.oauthSessionId,
|
||||||
|
credential: sealPassword('another-password'),
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechecks sanctions before consuming a migrated password setup', async () => {
|
||||||
|
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||||
|
kakaoId: 'sanctioned-setup-id',
|
||||||
|
kakaoEmail: 'sanctioned-setup@example.com',
|
||||||
|
});
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: 'sanctioned-setup-user',
|
||||||
|
password: 'legacy-password',
|
||||||
|
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||||
|
});
|
||||||
|
user.passwordResetRequired = true;
|
||||||
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
|
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
|
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||||
|
await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: login.oauthSessionId,
|
||||||
|
credential: sealPassword('blocked-password'),
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes password setup when the provider identity changes before submission', async () => {
|
||||||
|
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||||
|
kakaoId: 'setup-target-id',
|
||||||
|
kakaoEmail: 'setup-target@example.com',
|
||||||
|
});
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: 'setup-target-user',
|
||||||
|
password: 'legacy-password',
|
||||||
|
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||||
|
});
|
||||||
|
user.passwordResetRequired = true;
|
||||||
|
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||||
|
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||||
|
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||||
|
kakaoProfile.id = 'changed-provider-id';
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: login.oauthSessionId,
|
||||||
|
credential: sealPassword('new-target-password'),
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
|
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||||
|
await expect(
|
||||||
|
caller.auth.kakaoSetPassword({
|
||||||
|
oauthSessionId: login.oauthSessionId,
|
||||||
|
credential: sealPassword('new-target-password'),
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
||||||
@@ -1147,6 +1257,7 @@ describe('account self service', () => {
|
|||||||
username: 'self-service',
|
username: 'self-service',
|
||||||
password: 'current-password',
|
password: 'current-password',
|
||||||
});
|
});
|
||||||
|
user.passwordResetRequired = true;
|
||||||
const session = await sessions.createSession(user);
|
const session = await sessions.createSession(user);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
|
|||||||
|
|
||||||
const refreshed = await users.findById(user.id);
|
const refreshed = await users.findById(user.id);
|
||||||
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
|
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
|
||||||
|
expect(refreshed?.passwordResetRequired).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('revokes the session and schedules deletion after 30 days', async () => {
|
it('revokes the session and schedules deletion after 30 days', async () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
|
|||||||
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
||||||
passwordHash: 'unused',
|
passwordHash: 'unused',
|
||||||
passwordSalt: '',
|
passwordSalt: '',
|
||||||
|
passwordResetRequired: false,
|
||||||
createdAt: graceStartedAt.toISOString(),
|
createdAt: graceStartedAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +91,25 @@ describe('local account profile policy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not trust a migrated Kakao marker without a valid provider ID', () => {
|
||||||
|
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||||
|
user.oauthType = 'KAKAO';
|
||||||
|
user.oauthId = ' ';
|
||||||
|
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
|
||||||
|
const policy = resolveLocalAccountProfilePolicy({
|
||||||
|
profile: 'che',
|
||||||
|
defaultGraceDays: 0,
|
||||||
|
user,
|
||||||
|
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(policy).toMatchObject({
|
||||||
|
kakaoVerified: false,
|
||||||
|
requiresKakaoVerification: true,
|
||||||
|
accessAllowed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('extends account access with an administrator override without widening general creation grace', () => {
|
it('extends account access with an administrator override without widening general creation grace', () => {
|
||||||
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
|
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
|
||||||
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
|
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
|
||||||
|
|||||||
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
|||||||
});
|
});
|
||||||
sessionIds.add(session.id);
|
sessionIds.add(session.id);
|
||||||
|
|
||||||
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
|
await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0);
|
||||||
|
const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]);
|
||||||
|
expect(consumed.filter((value) => value !== null)).toHaveLength(1);
|
||||||
|
expect(consumed.find((value) => value !== null)).toMatchObject({
|
||||||
id: session.id,
|
id: session.id,
|
||||||
intent: 'link_existing',
|
intent: 'link_existing',
|
||||||
targetUserId,
|
targetUserId,
|
||||||
email: 'retained@example.test',
|
email: 'retained@example.test',
|
||||||
});
|
});
|
||||||
await expect(store.consumeSession(session.id)).resolves.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('atomically consumes a successful code once', async () => {
|
it('atomically consumes a successful code once', async () => {
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
|
|||||||
});
|
});
|
||||||
user.passwordSalt = 'core-salt';
|
user.passwordSalt = 'core-salt';
|
||||||
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
||||||
|
user.passwordResetRequired = true;
|
||||||
|
|
||||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||||
expect(user.passwordSalt).toBe('');
|
expect(user.passwordSalt).toBe('');
|
||||||
|
expect(user.passwordResetRequired).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
||||||
@@ -52,10 +54,12 @@ describe('password credential compatibility', () => {
|
|||||||
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
||||||
user.passwordSalt = userSalt;
|
user.passwordSalt = userSalt;
|
||||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||||
|
user.passwordResetRequired = true;
|
||||||
|
|
||||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||||
expect(user.passwordSalt).toBe('');
|
expect(user.passwordSalt).toBe('');
|
||||||
|
expect(user.passwordResetRequired).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not accept an imported ref credential without the matching global salt', async () => {
|
it('does not accept an imported ref credential without the matching global salt', async () => {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
|
gatewaySchemaHead: '20260817000000_add_password_reset_required',
|
||||||
gameSchemaHead: '20260816000000_add_read_model_change_journal',
|
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,11 +61,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
|
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
|
||||||
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
|
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
|
||||||
const workspace = await createWorkspace(
|
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
|
||||||
'20260801000000_gateway',
|
|
||||||
'20260801000000_game',
|
|
||||||
futureProtocol
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(readReleaseManifest(workspace)).rejects.toThrow(
|
await expect(readReleaseManifest(workspace)).rejects.toThrow(
|
||||||
`Release requires controller protocol ${futureProtocol}`
|
`Release requires controller protocol ${futureProtocol}`
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { generateKeyPairSync } from 'node:crypto';
|
||||||
|
|
||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
|
|||||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||||
|
|
||||||
|
const installPasswordSetupFixture = async (page: Page) => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
calls.push(operation);
|
||||||
|
if (operation === 'me') return response(null);
|
||||||
|
if (operation === 'lobby.notice') return response('');
|
||||||
|
if (operation === 'lobby.profiles') return response([]);
|
||||||
|
if (operation === 'auth.passwordKey') {
|
||||||
|
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
||||||
|
}
|
||||||
|
if (operation === 'auth.kakaoExchange') {
|
||||||
|
return response({
|
||||||
|
status: 'password_setup',
|
||||||
|
oauthSessionId: '11111111-1111-4111-8111-111111111112',
|
||||||
|
email: 'migrated@example.test',
|
||||||
|
successStatus: 'login',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'auth.kakaoSetPassword') {
|
||||||
|
return response({
|
||||||
|
status: 'otp',
|
||||||
|
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
expiresAt: '2026-08-17T12:03:00.000Z',
|
||||||
|
attemptsRemaining: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
@@ -108,4 +147,27 @@ for (const viewport of [
|
|||||||
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||||
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
|
||||||
|
const calls = await installPasswordSetupFixture(page);
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||||
|
|
||||||
|
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
|
||||||
|
await expect(form).toBeVisible();
|
||||||
|
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
|
||||||
|
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
|
||||||
|
const geometry = await form.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { width: rect.width, right: rect.right };
|
||||||
|
});
|
||||||
|
await form.getByLabel('새 비밀번호').fill('new-password-value');
|
||||||
|
await form.getByLabel('비밀번호 확인').fill('new-password-value');
|
||||||
|
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
|
||||||
|
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
|
||||||
|
expect(geometry.width).toBeGreaterThan(300);
|
||||||
|
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const submitting = ref(false);
|
|||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const infoMessage = ref('');
|
const infoMessage = ref('');
|
||||||
const oauthSessionId = ref('');
|
const oauthSessionId = ref('');
|
||||||
|
const passwordSetupSessionId = ref('');
|
||||||
|
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
|
|||||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.status === 'password_setup') {
|
||||||
|
passwordSetupSessionId.value = result.oauthSessionId;
|
||||||
|
passwordSetupSuccessStatus.value = result.successStatus;
|
||||||
|
email.value = result.email;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (result.status === 'account_recovery') {
|
if (result.status === 'account_recovery') {
|
||||||
accountRecovery.value = result;
|
accountRecovery.value = result;
|
||||||
email.value = result.email;
|
email.value = result.email;
|
||||||
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
|
|||||||
await router.replace('/lobby');
|
await router.replace('/lobby');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.status === 'password_setup') {
|
||||||
|
passwordSetupSessionId.value = result.oauthSessionId;
|
||||||
|
passwordSetupSuccessStatus.value = result.successStatus;
|
||||||
|
email.value = result.email;
|
||||||
|
return;
|
||||||
|
}
|
||||||
oauthSessionId.value = result.oauthSessionId;
|
oauthSessionId.value = result.oauthSessionId;
|
||||||
email.value = result.email;
|
email.value = result.email;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setMigratedPassword = async (): Promise<void> => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const credential = await sealPassword(password.value);
|
||||||
|
const result = await trpc.auth.kakaoSetPassword.mutate({
|
||||||
|
oauthSessionId: passwordSetupSessionId.value,
|
||||||
|
credential,
|
||||||
|
});
|
||||||
|
password.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
passwordSetupSessionId.value = '';
|
||||||
|
if (result.status === 'otp') {
|
||||||
|
otpChallenge.value = result;
|
||||||
|
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||||
|
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const register = async (): Promise<void> => {
|
const register = async (): Promise<void> => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
@@ -156,7 +200,15 @@ onMounted(() => {
|
|||||||
<main id="oauth-container">
|
<main id="oauth-container">
|
||||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||||
<section class="oauth-card">
|
<section class="oauth-card">
|
||||||
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
|
<h2>
|
||||||
|
{{
|
||||||
|
accountRecovery
|
||||||
|
? '카카오 계정 연결 확인'
|
||||||
|
: passwordSetupSessionId
|
||||||
|
? '새 비밀번호 설정'
|
||||||
|
: '회원가입'
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||||
@@ -181,6 +233,46 @@ onMounted(() => {
|
|||||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
|
v-else-if="passwordSetupSessionId"
|
||||||
|
class="password-setup-form"
|
||||||
|
aria-label="새 비밀번호 설정"
|
||||||
|
@submit.prevent="setMigratedPassword"
|
||||||
|
>
|
||||||
|
<p class="oauth-message">
|
||||||
|
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요.
|
||||||
|
</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="migrated-password-email">카카오 이메일</label>
|
||||||
|
<input id="migrated-password-email" :value="email" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="migrated-password">새 비밀번호</label>
|
||||||
|
<input
|
||||||
|
id="migrated-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
minlength="6"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="migrated-password-confirm">비밀번호 확인</label>
|
||||||
|
<input
|
||||||
|
id="migrated-password-confirm"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
minlength="6"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="register-button" type="submit" :disabled="submitting">
|
||||||
|
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
|
||||||
|
</button>
|
||||||
|
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||||
|
</form>
|
||||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="oauth-email">카카오 이메일</label>
|
<label for="oauth-email">카카오 이메일</label>
|
||||||
|
|||||||
+75
-32
@@ -9,6 +9,13 @@ PostgreSQL advisory locks serialize an apply per profile, and every target row
|
|||||||
uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
|
uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
|
||||||
repeatable.
|
repeatable.
|
||||||
|
|
||||||
|
Gateway apply is one PostgreSQL transaction. A game apply records a
|
||||||
|
`legacy_archive.import_run`: archive and current-user projection writes commit
|
||||||
|
together with `COMPLETED`, while a rollback leaves a `FAILED` run record. A
|
||||||
|
repeat import updates archive-owned rows but does not replace a live Gateway
|
||||||
|
account's password, reset status, login/display identity, OAuth connection,
|
||||||
|
roles, sanctions, consent, icon or login timestamps.
|
||||||
|
|
||||||
The source of truth for eligibility is the checked ref schema, not every table
|
The source of truth for eligibility is the checked ref schema, not every table
|
||||||
that happens to exist in a dump. Tables outside that schema remain only in the
|
that happens to exist in a dump. Tables outside that schema remain only in the
|
||||||
recovery dump.
|
recovery dump.
|
||||||
@@ -28,18 +35,26 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
|
|||||||
that UUID, so references such as `ng_old_generals.owner` remain stable even
|
that UUID, so references such as `ng_old_generals.owner` remain stable even
|
||||||
when an old account was deleted before the dump.
|
when an old account was deleted before the dump.
|
||||||
|
|
||||||
Kakao members retain `oauth_id`, email and metadata. A parseable legacy
|
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
|
||||||
|
required before an imported row is marked Kakao-verified. A parseable legacy
|
||||||
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
||||||
remaining KakaoTalk ownership-proof interval instead of forcing an immediate
|
remaining KakaoTalk ownership-proof interval instead of forcing an immediate
|
||||||
message at cutover. Cutover also sets `kakao_verified_at` and
|
message at cutover. Valid provider rows also receive `kakao_verified_at`; all
|
||||||
|
rows receive
|
||||||
`kakao_grace_started_at` to the migration time and starts the local-account
|
`kakao_grace_started_at` to the migration time and starts the local-account
|
||||||
verification grace period there. Source rows without an OAuth ID retain their
|
verification grace period there. Source rows without an OAuth ID retain their
|
||||||
metadata, but the importer does not invent a provider identifier.
|
metadata but are not treated as verified, and the importer never invents a
|
||||||
|
provider identifier.
|
||||||
|
|
||||||
Legacy password hashes remain usable when gateway-api has
|
Every imported 128-hex legacy password is marked `password_reset_required`.
|
||||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored
|
The dump contains the per-user salt but not Ref's installation-wide salt, so
|
||||||
value to Argon2id. A test-only account can instead be reset with the CLI and a
|
the dump alone cannot validate the old plaintext password. If the original
|
||||||
mode-0600 password file:
|
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is recovered through the runtime secret,
|
||||||
|
a successful password login upgrades the value to Argon2id and clears the
|
||||||
|
flag. Otherwise, a Kakao login (including a confirmed retained-email relink)
|
||||||
|
issues a one-time password-setup challenge before any normal session; the new
|
||||||
|
password is sent in the existing RSA envelope and clears the flag. Accounts
|
||||||
|
without usable Kakao recovery require the CLI and a mode-0600 password file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
|
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
|
||||||
@@ -50,24 +65,43 @@ The password is never accepted as an argument or printed.
|
|||||||
|
|
||||||
### Game profiles
|
### Game profiles
|
||||||
|
|
||||||
| Legacy table | Target | Policy |
|
| Legacy table | Dedicated target | Policy |
|
||||||
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- |
|
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
|
||||||
| `ng_games` | `ng_games` | Preserve completed season metadata |
|
| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
|
||||||
| `hall` | `hall` | Preserve hall-of-fame rows |
|
| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
|
||||||
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner |
|
| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
|
||||||
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs |
|
| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
|
||||||
| `emperior` | `emperior` | Preserve dynasty detail and legacy key |
|
| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
|
||||||
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
|
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
|
||||||
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
|
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
|
||||||
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
|
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
|
||||||
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
|
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
|
||||||
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
|
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
|
||||||
| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots |
|
| `ng_history` | `legacy_archive.yearbook` | Preserve map, nation, global history and global action snapshots |
|
||||||
|
|
||||||
|
The archive schema is shared by all game-profile schemas in the PostgreSQL
|
||||||
|
database. Every natural key contains `source_profile`; the accepted profiles
|
||||||
|
are `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, and `hwe`. This prevents equal
|
||||||
|
legacy IDs from different servers from colliding while allowing any profile API
|
||||||
|
to read one central archive.
|
||||||
|
|
||||||
|
`ng_games.date` is retained as `legacy_date`. The displayed opening date uses
|
||||||
|
`env.opentime`, then `env.starttime`, then `ng_games.date`. The dumps do not
|
||||||
|
carry a trustworthy completion timestamp, so `completed_at` remains null
|
||||||
|
instead of treating the opening date as completion.
|
||||||
|
|
||||||
|
`ng_old_generals.data` is adapted at import time to
|
||||||
|
`ArchivedGeneralSnapshotV1`. Both old `leader/power` with
|
||||||
|
`dex0/10/20/30/40` and newer `leadership/strength` with `dex1..5` map to one
|
||||||
|
shape. Missing battle aggregates and logs are `null` plus explicit
|
||||||
|
`availability`, never fabricated zeroes. The source JSON remains in
|
||||||
|
`legacy_archive.general.raw_data` for recovery, but no API returns it.
|
||||||
|
|
||||||
The source contains legitimate duplicate `(server_id, nation)` old-nation rows
|
The source contains legitimate duplicate `(server_id, nation)` old-nation rows
|
||||||
and `(server_id, year, month)` history rows. `source_id` is consequently part of
|
and `(server_id, year, month)` history rows. `source_id` is consequently part of
|
||||||
the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated
|
their current-schema archive keys, while the dedicated legacy archive uses
|
||||||
rows use the legacy primary key. This avoids a lossy last-row-wins upsert.
|
`(source_profile, legacy_id)` from the original primary key. This avoids a lossy
|
||||||
|
last-row-wins upsert and keeps runtime current archives separate.
|
||||||
|
|
||||||
Current-season actor/world/queue/lock/message/market/vote state is explicitly
|
Current-season actor/world/queue/lock/message/market/vote state is explicitly
|
||||||
excluded. In particular, `general`, `city`, `nation`, their turn queues,
|
excluded. In particular, `general`, `city`, `nation`, their turn queues,
|
||||||
@@ -108,35 +142,44 @@ season or as a substitute for the long-lived archive cutover procedure.
|
|||||||
archive owner from the game session and never accepts an owner ID from the
|
archive owner from the game session and never accepts an owner ID from the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with
|
- `archive.myPastPlays` reads the central legacy archive across profiles and
|
||||||
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional
|
current runtime archives, tags each source, and suppresses a current-schema
|
||||||
`emperior` row. It returns summary fields and a link target for the existing
|
duplicate when the central legacy copy exists.
|
||||||
public dynasty/nation detail.
|
- `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes
|
||||||
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in
|
the session owner in the database predicate. A foreign or missing record uses
|
||||||
the database predicate before returning `data.history`. A foreign or missing
|
the same not-found response.
|
||||||
record uses the same not-found response.
|
- The detail DTO feeds the same `GeneralBasicCard`, battle summary,
|
||||||
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API
|
`LegacyGeneralProgress`, and record panels used by My Page/Battle Center.
|
||||||
normalizes both to a newest-first string array, and the frontend renders plain
|
Missing battle/mastery/log channels show an explicit not-preserved state.
|
||||||
text rather than archived markup.
|
- Legacy `data.history` may be either an array or a `<br>`-joined string. It is
|
||||||
|
normalized to plain-text archive entries; archived markup is never rendered
|
||||||
|
as trusted HTML.
|
||||||
- Runtime death and unification archival writes the current general
|
- Runtime death and unification archival writes the current general
|
||||||
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
|
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
|
||||||
seasons remain compatible with imported rows.
|
seasons remain compatible with imported rows.
|
||||||
|
|
||||||
|
Hall-of-fame and dynasty APIs and pages take an explicit `current` or `legacy`
|
||||||
|
source. Legacy results use the central archive and show the source profile;
|
||||||
|
they are never merged into the current rankings or current dynasty list.
|
||||||
|
|
||||||
## Cutover procedure
|
## Cutover procedure
|
||||||
|
|
||||||
1. Keep the original compressed dumps immutable and restore each source to a
|
1. Keep the original compressed dumps immutable and restore each source to a
|
||||||
private MariaDB instance.
|
private MariaDB instance.
|
||||||
2. Deploy the gateway and game Prisma migrations to empty staging databases.
|
2. Deploy the gateway and game Prisma migrations to empty staging databases.
|
||||||
3. Run gateway and each non-empty profile without `--apply`; archive the JSON
|
3. Run gateway and each non-empty official profile without `--apply`; archive the JSON
|
||||||
counts and excluded-table reasons.
|
counts and excluded-table reasons.
|
||||||
4. Compare source counts, malformed JSON checks and duplicate natural-key
|
4. Compare source counts, malformed JSON checks and duplicate natural-key
|
||||||
counts. Stop on unexplained drift.
|
counts. Stop on unexplained drift.
|
||||||
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
|
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
|
||||||
run the same commands with `--apply`.
|
run the same commands with `--apply`.
|
||||||
6. Repeat each apply. Counts must remain unchanged.
|
6. Repeat each apply. Counts must remain unchanged; verify the newest
|
||||||
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership,
|
`legacy_archive.import_run` is `COMPLETED` and current Gateway credentials
|
||||||
old-nation/history duplicate preservation, `/past-plays` list/detail access,
|
are unchanged.
|
||||||
foreign-owner denial and the dynasty link.
|
7. Verify valid/invalid Kakao-ID classification, password-reset-required rows,
|
||||||
|
Kakao password setup, CLI fallback, archive ownership, canonical source
|
||||||
|
format counts, opening dates, `/past-plays`, foreign-owner denial, legacy
|
||||||
|
Hall and legacy Dynasty source switches.
|
||||||
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
||||||
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
||||||
upserts.
|
upserts.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export * from './ranking/types.js';
|
|||||||
export * from './ranking/legacyColor.js';
|
export * from './ranking/legacyColor.js';
|
||||||
export * from './auth/accountIconProjection.js';
|
export * from './auth/accountIconProjection.js';
|
||||||
export * from './logging/formatLegacyLogHtml.js';
|
export * from './logging/formatLegacyLogHtml.js';
|
||||||
|
export * from './legacyArchive/ArchivedGeneralSnapshot.js';
|
||||||
export * from './gateway/profileStatus.js';
|
export * from './gateway/profileStatus.js';
|
||||||
export * from './game/accessPenalty.js';
|
export * from './game/accessPenalty.js';
|
||||||
export * from './http/trpcTransport.js';
|
export * from './http/trpcTransport.js';
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
export type ArchivedJsonValue =
|
||||||
|
null | boolean | number | string | ArchivedJsonValue[] | { [key: string]: ArchivedJsonValue };
|
||||||
|
|
||||||
|
export const ARCHIVED_GENERAL_SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
|
export const LEGACY_ARCHIVE_PROFILES = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const;
|
||||||
|
export type LegacyArchiveProfile = (typeof LEGACY_ARCHIVE_PROFILES)[number];
|
||||||
|
|
||||||
|
export const isLegacyArchiveProfile = (value: string): value is LegacyArchiveProfile =>
|
||||||
|
(LEGACY_ARCHIVE_PROFILES as readonly string[]).includes(value);
|
||||||
|
|
||||||
|
export type ArchivedGeneralSourceFormat = 'legacy-flat-v0' | 'ref-flat-v1' | 'core-snapshot-v1' | 'unknown';
|
||||||
|
|
||||||
|
export interface ArchivedGeneralSnapshotV1 {
|
||||||
|
schemaVersion: typeof ARCHIVED_GENERAL_SCHEMA_VERSION;
|
||||||
|
identity: {
|
||||||
|
name: string;
|
||||||
|
picture: string | null;
|
||||||
|
imageServer: number | null;
|
||||||
|
npcState: number | null;
|
||||||
|
nationId: number | null;
|
||||||
|
cityId: number | null;
|
||||||
|
officerLevel: number | null;
|
||||||
|
officerCity: number | null;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
leadership: number | null;
|
||||||
|
strength: number | null;
|
||||||
|
intelligence: number | null;
|
||||||
|
leadershipExperience: number | null;
|
||||||
|
strengthExperience: number | null;
|
||||||
|
intelligenceExperience: number | null;
|
||||||
|
};
|
||||||
|
progression: {
|
||||||
|
experience: number | null;
|
||||||
|
experienceLevel: number | null;
|
||||||
|
dedication: number | null;
|
||||||
|
dedicationLevel: number | null;
|
||||||
|
age: number | null;
|
||||||
|
startAge: number | null;
|
||||||
|
bornYear: number | null;
|
||||||
|
deadYear: number | null;
|
||||||
|
};
|
||||||
|
traits: {
|
||||||
|
personality: string | null;
|
||||||
|
specialDomestic: string | null;
|
||||||
|
specialWar: string | null;
|
||||||
|
};
|
||||||
|
resources: {
|
||||||
|
gold: number | null;
|
||||||
|
rice: number | null;
|
||||||
|
crew: number | null;
|
||||||
|
crewType: string | null;
|
||||||
|
train: number | null;
|
||||||
|
morale: number | null;
|
||||||
|
injury: number | null;
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
horse: string | null;
|
||||||
|
weapon: string | null;
|
||||||
|
book: string | null;
|
||||||
|
item: string | null;
|
||||||
|
};
|
||||||
|
mastery: {
|
||||||
|
infantry: number | null;
|
||||||
|
archery: number | null;
|
||||||
|
cavalry: number | null;
|
||||||
|
special: number | null;
|
||||||
|
siege: number | null;
|
||||||
|
};
|
||||||
|
battle: {
|
||||||
|
battles: number | null;
|
||||||
|
wins: number | null;
|
||||||
|
losses: number | null;
|
||||||
|
fireSuccesses: number | null;
|
||||||
|
kills: number | null;
|
||||||
|
deaths: number | null;
|
||||||
|
killedCrew: number | null;
|
||||||
|
lostCrew: number | null;
|
||||||
|
winRate: number | null;
|
||||||
|
killRate: number | null;
|
||||||
|
recentWar: string | null;
|
||||||
|
tactics: {
|
||||||
|
total: { wins: number | null; draws: number | null; losses: number | null };
|
||||||
|
leadership: { wins: number | null; draws: number | null; losses: number | null };
|
||||||
|
intelligence: { wins: number | null; draws: number | null; losses: number | null };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
history: string[];
|
||||||
|
availability: {
|
||||||
|
mastery: boolean;
|
||||||
|
battleAggregates: boolean;
|
||||||
|
tactics: boolean;
|
||||||
|
history: boolean;
|
||||||
|
battleDetailLogs: false;
|
||||||
|
battleResultLogs: false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, ArchivedJsonValue | undefined>;
|
||||||
|
|
||||||
|
const asRecord = (value: ArchivedJsonValue | undefined): JsonRecord =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||||
|
|
||||||
|
const finiteNumber = (value: ArchivedJsonValue | undefined): number | null => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstNumber = (...values: Array<ArchivedJsonValue | undefined>): number | null => {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = finiteNumber(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (value: ArchivedJsonValue | undefined): string | null => {
|
||||||
|
if (typeof value === 'string') return value.trim() === '' ? null : value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstText = (...values: Array<ArchivedJsonValue | undefined>): string | null => {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = text(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyLines = (value: ArchivedJsonValue | undefined): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') return [];
|
||||||
|
return value
|
||||||
|
.split(/<br\s*\/?>/iu)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rate = (numerator: number | null, denominator: number | null): number | null =>
|
||||||
|
numerator === null || denominator === null || denominator <= 0
|
||||||
|
? null
|
||||||
|
: Math.round((numerator / denominator) * 10_000) / 100;
|
||||||
|
|
||||||
|
export const detectArchivedGeneralSourceFormat = (raw: ArchivedJsonValue): ArchivedGeneralSourceFormat => {
|
||||||
|
const data = asRecord(raw);
|
||||||
|
if (Object.keys(asRecord(data.stats)).length > 0 || data.schemaVersion === 1) return 'core-snapshot-v1';
|
||||||
|
if ('leadership' in data || 'strength' in data || 'dex1' in data) return 'ref-flat-v1';
|
||||||
|
if ('leader' in data || 'power' in data || 'dex0' in data) return 'legacy-flat-v0';
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeArchivedGeneral = (
|
||||||
|
raw: ArchivedJsonValue,
|
||||||
|
fallbackName: string
|
||||||
|
): { sourceFormat: ArchivedGeneralSourceFormat; snapshot: ArchivedGeneralSnapshotV1 } => {
|
||||||
|
const data = asRecord(raw);
|
||||||
|
const identity = asRecord(data.identity);
|
||||||
|
const stats = asRecord(data.stats);
|
||||||
|
const role = asRecord(data.role);
|
||||||
|
const items = asRecord(data.items);
|
||||||
|
const progression = asRecord(data.progression);
|
||||||
|
const traits = asRecord(data.traits);
|
||||||
|
const resources = asRecord(data.resources);
|
||||||
|
const nestedMastery = asRecord(data.mastery);
|
||||||
|
const nestedBattle = asRecord(data.battle);
|
||||||
|
const nestedTactics = asRecord(nestedBattle.tactics);
|
||||||
|
const totalTactics = asRecord(nestedTactics.total);
|
||||||
|
const leadershipTactics = asRecord(nestedTactics.leadership);
|
||||||
|
const intelligenceTactics = asRecord(nestedTactics.intelligence);
|
||||||
|
const oldMastery = 'dex0' in data;
|
||||||
|
const masteryValues = oldMastery
|
||||||
|
? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40]
|
||||||
|
: [
|
||||||
|
data.dex1 ?? nestedMastery.infantry,
|
||||||
|
data.dex2 ?? nestedMastery.archery,
|
||||||
|
data.dex3 ?? nestedMastery.cavalry,
|
||||||
|
data.dex4 ?? nestedMastery.special,
|
||||||
|
data.dex5 ?? nestedMastery.siege,
|
||||||
|
];
|
||||||
|
const mastery = masteryValues.map(finiteNumber);
|
||||||
|
const battles = firstNumber(data.warnum, nestedBattle.battles);
|
||||||
|
const wins = firstNumber(data.killnum, nestedBattle.wins);
|
||||||
|
const losses = firstNumber(data.deathnum, nestedBattle.losses);
|
||||||
|
const killedCrew = firstNumber(data.killcrew, nestedBattle.killedCrew);
|
||||||
|
const lostCrew = firstNumber(data.deathcrew, nestedBattle.lostCrew);
|
||||||
|
const history = historyLines(data.history);
|
||||||
|
const tacticValues = [
|
||||||
|
data.ttw ?? totalTactics.wins,
|
||||||
|
data.ttd ?? totalTactics.draws,
|
||||||
|
data.ttl ?? totalTactics.losses,
|
||||||
|
data.tlw ?? leadershipTactics.wins,
|
||||||
|
data.tld ?? leadershipTactics.draws,
|
||||||
|
data.tll ?? leadershipTactics.losses,
|
||||||
|
data.tiw ?? intelligenceTactics.wins,
|
||||||
|
data.tid ?? intelligenceTactics.draws,
|
||||||
|
data.til ?? intelligenceTactics.losses,
|
||||||
|
];
|
||||||
|
const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceFormat: detectArchivedGeneralSourceFormat(raw),
|
||||||
|
snapshot: {
|
||||||
|
schemaVersion: ARCHIVED_GENERAL_SCHEMA_VERSION,
|
||||||
|
identity: {
|
||||||
|
name: firstText(data.name, identity.name) ?? fallbackName,
|
||||||
|
picture: firstText(data.picture, identity.picture),
|
||||||
|
imageServer: firstNumber(data.imageServer, data.imgsvr, identity.imageServer),
|
||||||
|
npcState: firstNumber(data.npcState, data.npc, identity.npcState),
|
||||||
|
nationId: firstNumber(data.nationId, data.nation, identity.nationId),
|
||||||
|
cityId: firstNumber(data.cityId, data.city, identity.cityId),
|
||||||
|
officerLevel: firstNumber(data.officerLevel, data.officer_level, data.level, identity.officerLevel),
|
||||||
|
officerCity: firstNumber(data.officerCity, data.officer_city, identity.officerCity),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
leadership: firstNumber(data.leadership, data.leader, stats.leadership),
|
||||||
|
strength: firstNumber(data.strength, data.power, stats.strength),
|
||||||
|
intelligence: firstNumber(data.intelligence, data.intel, stats.intelligence),
|
||||||
|
leadershipExperience: firstNumber(
|
||||||
|
data.leadershipExperience,
|
||||||
|
data.leadership_exp,
|
||||||
|
stats.leadershipExperience
|
||||||
|
),
|
||||||
|
strengthExperience: firstNumber(data.strengthExperience, data.strength_exp, stats.strengthExperience),
|
||||||
|
intelligenceExperience: firstNumber(
|
||||||
|
data.intelligenceExperience,
|
||||||
|
data.intel_exp,
|
||||||
|
stats.intelligenceExperience
|
||||||
|
),
|
||||||
|
},
|
||||||
|
progression: {
|
||||||
|
experience: firstNumber(data.experience, progression.experience),
|
||||||
|
experienceLevel: firstNumber(data.experienceLevel, data.explevel, progression.experienceLevel),
|
||||||
|
dedication: firstNumber(data.dedication, progression.dedication),
|
||||||
|
dedicationLevel: firstNumber(data.dedicationLevel, data.dedlevel, progression.dedicationLevel),
|
||||||
|
age: firstNumber(data.age, progression.age),
|
||||||
|
startAge: firstNumber(data.startAge, data.startage, progression.startAge),
|
||||||
|
bornYear: firstNumber(data.bornYear, data.bornyear, progression.bornYear),
|
||||||
|
deadYear: firstNumber(data.deadYear, data.deadyear, progression.deadYear),
|
||||||
|
},
|
||||||
|
traits: {
|
||||||
|
personality: firstText(data.personalCode, data.personal, role.personality, traits.personality),
|
||||||
|
specialDomestic: firstText(
|
||||||
|
data.specialCode,
|
||||||
|
data.special,
|
||||||
|
role.specialDomestic,
|
||||||
|
traits.specialDomestic
|
||||||
|
),
|
||||||
|
specialWar: firstText(data.special2Code, data.special2, role.specialWar, traits.specialWar),
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
|
gold: firstNumber(data.gold, resources.gold),
|
||||||
|
rice: firstNumber(data.rice, resources.rice),
|
||||||
|
crew: firstNumber(data.crew, resources.crew),
|
||||||
|
crewType: firstText(data.crewType, data.crewtype, resources.crewType),
|
||||||
|
train: firstNumber(data.train, resources.train),
|
||||||
|
morale: firstNumber(data.morale, data.atmos, resources.morale),
|
||||||
|
injury: firstNumber(data.injury, resources.injury),
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
horse: firstText(items.horse, data.horse),
|
||||||
|
weapon: firstText(items.weapon, data.weapon, data.weap),
|
||||||
|
book: firstText(items.book, data.book),
|
||||||
|
item: firstText(items.item, data.item),
|
||||||
|
},
|
||||||
|
mastery: {
|
||||||
|
infantry: mastery[0] ?? null,
|
||||||
|
archery: mastery[1] ?? null,
|
||||||
|
cavalry: mastery[2] ?? null,
|
||||||
|
special: mastery[3] ?? null,
|
||||||
|
siege: mastery[4] ?? null,
|
||||||
|
},
|
||||||
|
battle: {
|
||||||
|
battles,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses),
|
||||||
|
kills: firstNumber(nestedBattle.kills, wins),
|
||||||
|
deaths: firstNumber(nestedBattle.deaths, losses),
|
||||||
|
killedCrew,
|
||||||
|
lostCrew,
|
||||||
|
winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles),
|
||||||
|
killRate: firstNumber(nestedBattle.killRate) ?? rate(killedCrew, lostCrew),
|
||||||
|
recentWar: firstText(data.recentWar, data.recent_war, nestedBattle.recentWar),
|
||||||
|
tactics: {
|
||||||
|
total: {
|
||||||
|
wins: firstNumber(data.ttw, totalTactics.wins),
|
||||||
|
draws: firstNumber(data.ttd, totalTactics.draws),
|
||||||
|
losses: firstNumber(data.ttl, totalTactics.losses),
|
||||||
|
},
|
||||||
|
leadership: {
|
||||||
|
wins: firstNumber(data.tlw, leadershipTactics.wins),
|
||||||
|
draws: firstNumber(data.tld, leadershipTactics.draws),
|
||||||
|
losses: firstNumber(data.tll, leadershipTactics.losses),
|
||||||
|
},
|
||||||
|
intelligence: {
|
||||||
|
wins: firstNumber(data.tiw, intelligenceTactics.wins),
|
||||||
|
draws: firstNumber(data.tid, intelligenceTactics.draws),
|
||||||
|
losses: firstNumber(data.til, intelligenceTactics.losses),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history,
|
||||||
|
availability: {
|
||||||
|
mastery: mastery.some((value) => value !== null),
|
||||||
|
battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null),
|
||||||
|
tactics: tacticsAvailable,
|
||||||
|
history: history.length > 0,
|
||||||
|
battleDetailLogs: false,
|
||||||
|
battleResultLogs: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "app_user"
|
||||||
|
ADD COLUMN "password_reset_required" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
UPDATE "app_user"
|
||||||
|
SET "password_reset_required" = TRUE
|
||||||
|
WHERE "password_hash" ~ '^[[:xdigit:]]{128}$';
|
||||||
@@ -82,6 +82,7 @@ model AppUser {
|
|||||||
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")
|
||||||
|
passwordResetRequired Boolean @default(false) @map("password_reset_required")
|
||||||
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")
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS "legacy_archive";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."import_run" (
|
||||||
|
"id" BIGSERIAL PRIMARY KEY,
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finished_at" TIMESTAMP(3),
|
||||||
|
"counts" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"source_format_summary" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"error" TEXT,
|
||||||
|
CONSTRAINT "legacy_archive_import_run_profile_check"
|
||||||
|
CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')),
|
||||||
|
CONSTRAINT "legacy_archive_import_run_status_check"
|
||||||
|
CHECK ("status" IN ('RUNNING', 'COMPLETED', 'FAILED'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_import_run_profile_started"
|
||||||
|
ON "legacy_archive"."import_run" ("source_profile", "started_at" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."game_history" (
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"server_id" TEXT NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"opened_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
"legacy_date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"winner_nation" INTEGER,
|
||||||
|
"map" TEXT,
|
||||||
|
"season" INTEGER NOT NULL,
|
||||||
|
"scenario" INTEGER NOT NULL,
|
||||||
|
"scenario_name" TEXT NOT NULL,
|
||||||
|
"raw_env" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
PRIMARY KEY ("source_profile", "server_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_game_history_opened"
|
||||||
|
ON "legacy_archive"."game_history" ("source_profile", "opened_at" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."general" (
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"server_id" TEXT NOT NULL,
|
||||||
|
"general_no" INTEGER NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"owner" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"last_yearmonth" INTEGER NOT NULL,
|
||||||
|
"turntime" TIMESTAMP(3) NOT NULL,
|
||||||
|
"schema_version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"source_format" TEXT NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"raw_data" JSONB NOT NULL,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
PRIMARY KEY ("source_profile", "server_id", "general_no"),
|
||||||
|
CONSTRAINT "legacy_archive_general_schema_version_check" CHECK ("schema_version" = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_general_owner_opened"
|
||||||
|
ON "legacy_archive"."general" ("owner", "source_profile", "server_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_general_name"
|
||||||
|
ON "legacy_archive"."general" ("source_profile", "server_id", "name");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."nation" (
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"server_id" TEXT NOT NULL,
|
||||||
|
"nation" INTEGER NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"archived_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
PRIMARY KEY ("source_profile", "legacy_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_nation_server"
|
||||||
|
ON "legacy_archive"."nation" ("source_profile", "server_id", "nation", "archived_at" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."hall" (
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"server_id" TEXT NOT NULL,
|
||||||
|
"season" INTEGER NOT NULL,
|
||||||
|
"scenario" INTEGER NOT NULL,
|
||||||
|
"general_no" INTEGER NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" DOUBLE PRECISION NOT NULL,
|
||||||
|
"owner" TEXT,
|
||||||
|
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
PRIMARY KEY ("source_profile", "server_id", "type", "general_no")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_hall_scenario"
|
||||||
|
ON "legacy_archive"."hall" ("source_profile", "season", "scenario", "type", "value" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."emperor" (
|
||||||
|
"id" BIGSERIAL PRIMARY KEY,
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"server_id" TEXT,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
CONSTRAINT "legacy_archive_emperor_source_key" UNIQUE ("source_profile", "legacy_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_emperor_server"
|
||||||
|
ON "legacy_archive"."emperor" ("source_profile", "server_id", "id" DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "legacy_archive"."yearbook" (
|
||||||
|
"source_profile" TEXT NOT NULL,
|
||||||
|
"legacy_id" INTEGER NOT NULL,
|
||||||
|
"profile_name" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"map" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"nations" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
"global_history" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
"global_action" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
"content_hash" TEXT NOT NULL,
|
||||||
|
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||||
|
PRIMARY KEY ("source_profile", "legacy_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "legacy_archive_yearbook_month"
|
||||||
|
ON "legacy_archive"."yearbook" ("source_profile", "profile_name", "year", "month", "legacy_id");
|
||||||
Generated
+3
@@ -535,6 +535,9 @@ importers:
|
|||||||
|
|
||||||
tools/legacy-db-migration:
|
tools/legacy-db-migration:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@sammo-ts/common':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/common
|
||||||
mariadb:
|
mariadb:
|
||||||
specifier: 3.5.3
|
specifier: 3.5.3
|
||||||
version: 3.5.3
|
version: 3.5.3
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
|
"gatewaySchemaHead": "20260817000000_add_password_reset_required",
|
||||||
"gameSchemaHead": "20260816000000_add_read_model_change_journal",
|
"gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
|
|||||||
route invokes it.
|
route invokes it.
|
||||||
|
|
||||||
The default mode is a read-only dry-run. `--apply` is required before any target
|
The default mode is a read-only dry-run. `--apply` is required before any target
|
||||||
write. PostgreSQL advisory locks prevent two applies for the same target. Every
|
write. PostgreSQL advisory locks prevent two applies for the same target.
|
||||||
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted
|
Gateway writes are transactional. Game archive writes and their completed
|
||||||
run can be repeated.
|
`legacy_archive.import_run` record are transactional. Stable legacy keys make
|
||||||
|
completed or interrupted runs repeatable.
|
||||||
|
|
||||||
## Source restore
|
## Source restore
|
||||||
|
|
||||||
@@ -37,6 +38,13 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate
|
|||||||
After reviewing the JSON counts and excluded-table reasons, add
|
After reviewing the JSON counts and excluded-table reasons, add
|
||||||
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
|
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
|
||||||
|
|
||||||
|
For game archives, `GAME_DATABASE_URL` points at that profile's Core schema.
|
||||||
|
The importer writes completed-history data to the shared
|
||||||
|
`legacy_archive` PostgreSQL schema and writes only inheritance projections to
|
||||||
|
the selected current profile schema. Accepted profiles are
|
||||||
|
`che,kwe,pwe,twe,nya,pya,hwe`; run them separately against the same PostgreSQL
|
||||||
|
database.
|
||||||
|
|
||||||
### Isolated current-season comparison fixture
|
### Isolated current-season comparison fixture
|
||||||
|
|
||||||
`current-season-fixture` is separate from the long-lived archive migration. It
|
`current-season-fixture` is separate from the long-lived archive migration. It
|
||||||
@@ -77,13 +85,17 @@ listed in the JSON result. This fixture is evidence for persisted-state and GUI
|
|||||||
comparison, not proof that the two engines consume RNG identically after the
|
comparison, not proof that the two engines consume RNG identically after the
|
||||||
next turn.
|
next turn.
|
||||||
|
|
||||||
Kakao members retain their OAuth ID, email, and OAuth metadata.
|
Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with
|
||||||
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time.
|
a non-empty OAuth ID receives `kakao_verified_at`.
|
||||||
|
`kakao_grace_started_at` is set to the migration time.
|
||||||
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
|
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
|
||||||
Kakao rows so a still-current “send to me” proof remains current after cutover.
|
Kakao rows so a still-current “send to me” proof remains current after cutover.
|
||||||
Legacy password hashes and salts are retained and upgraded to Argon2id after
|
Imported 128-hex password hashes are marked for reset. They can be upgraded to
|
||||||
the first successful login when
|
Argon2id after the first successful login only when the DB-external
|
||||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
|
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is safely recovered. Otherwise, a verified
|
||||||
|
Kakao flow requires a new password before session issuance. A non-Kakao account
|
||||||
|
uses the CLI reset below. Reapplying a dump preserves the target account's
|
||||||
|
current credential, OAuth, identity, roles, sanctions, consent, and login state.
|
||||||
|
|
||||||
Only tables present in the checked ref schemas are eligible. Extra tables found
|
Only tables present in the checked ref schemas are eligible. Extra tables found
|
||||||
in a dump, such as an old root `config` table, are left in the recovery dump and
|
in a dump, such as an old root `config` table, are left in the recovery dump and
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"migrate": "tsx src/cli.ts"
|
"migrate": "tsx src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sammo-ts/common": "workspace:*",
|
||||||
"mariadb": "3.5.3",
|
"mariadb": "3.5.3",
|
||||||
"pg": "^8.16.3"
|
"pg": "^8.16.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
import { createMariaPool, createPostgresPool } from './db.js';
|
import { createMariaPool, createPostgresPool } from './db.js';
|
||||||
import { migrateGame } from './game.js';
|
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, migrateGame } from './game.js';
|
||||||
import { migrateGateway } from './gateway.js';
|
import { migrateGateway } from './gateway.js';
|
||||||
import { hashPasswordForReset } from './password.js';
|
import { hashPasswordForReset } from './password.js';
|
||||||
import { migrateCurrentSeasonFixture } from './currentSeason.js';
|
import { migrateCurrentSeasonFixture } from './currentSeason.js';
|
||||||
@@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise<Record<string, unknow
|
|||||||
const hashed = await hashPasswordForReset(password);
|
const hashed = await hashPasswordForReset(password);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE "app_user"
|
`UPDATE "app_user"
|
||||||
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
|
SET "password_hash" = $1,
|
||||||
|
"password_salt" = $2,
|
||||||
|
"password_reset_required" = FALSE,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
WHERE "id" = $3`,
|
WHERE "id" = $3`,
|
||||||
[hashed.hash, hashed.salt, existing.rows[0]!.id]
|
[hashed.hash, hashed.salt, existing.rows[0]!.id]
|
||||||
);
|
);
|
||||||
@@ -142,7 +145,11 @@ const run = async (): Promise<void> => {
|
|||||||
const migratedAt = new Date();
|
const migratedAt = new Date();
|
||||||
if (options.command === 'gateway') {
|
if (options.command === 'gateway') {
|
||||||
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
|
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
|
||||||
const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null;
|
const targetUrl = process.env.GATEWAY_DATABASE_URL?.trim();
|
||||||
|
if (options.apply && !targetUrl) {
|
||||||
|
throw new Error('GATEWAY_DATABASE_URL is required with --apply');
|
||||||
|
}
|
||||||
|
const target = targetUrl ? createPostgresPool(targetUrl) : null;
|
||||||
try {
|
try {
|
||||||
const summary = await migrateGateway(source, target, options.apply, migratedAt);
|
const summary = await migrateGateway(source, target, options.apply, migratedAt);
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
@@ -156,6 +163,9 @@ const run = async (): Promise<void> => {
|
|||||||
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
|
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
|
||||||
throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`);
|
throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`);
|
||||||
}
|
}
|
||||||
|
if (options.command === 'game' && !isLegacyArchiveProfile(options.profile)) {
|
||||||
|
throw new Error(`game requires --profile ${LEGACY_ARCHIVE_PROFILES.join('|')}\n\n${usage}`);
|
||||||
|
}
|
||||||
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
|
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
|
||||||
const target =
|
const target =
|
||||||
options.apply || options.command === 'current-season-fixture'
|
options.apply || options.command === 'current-season-fixture'
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => {
|
|||||||
return `"${value}"`;
|
return `"${value}"`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const quoteQualifiedIdentifier = (value: string): string => {
|
||||||
|
const parts = value.split('.');
|
||||||
|
if (parts.length < 1 || parts.length > 2 || parts.some((part) => !IDENTIFIER.test(part))) {
|
||||||
|
throw new Error(`Unsafe SQL identifier: ${value}`);
|
||||||
|
}
|
||||||
|
return parts.map((part) => `"${part}"`).join('.');
|
||||||
|
};
|
||||||
|
|
||||||
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
|
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
|
||||||
|
|
||||||
export const createPostgresPool = (connectionString: string): pg.Pool => {
|
export const createPostgresPool = (connectionString: string): pg.Pool => {
|
||||||
@@ -94,7 +102,8 @@ export const upsertRows = async (
|
|||||||
client: PoolClient,
|
client: PoolClient,
|
||||||
table: string,
|
table: string,
|
||||||
rows: readonly TargetRow[],
|
rows: readonly TargetRow[],
|
||||||
conflictColumns: readonly string[]
|
conflictColumns: readonly string[],
|
||||||
|
options: { preserveOnConflict?: readonly string[] } = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -116,12 +125,13 @@ export const upsertRows = async (
|
|||||||
});
|
});
|
||||||
return `(${placeholders.join(', ')})`;
|
return `(${placeholders.join(', ')})`;
|
||||||
});
|
});
|
||||||
|
const preserved = new Set(options.preserveOnConflict ?? []);
|
||||||
const updates = columns
|
const updates = columns
|
||||||
.filter((column) => !conflictColumns.includes(column))
|
.filter((column) => !conflictColumns.includes(column) && !preserved.has(column))
|
||||||
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
|
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
|
||||||
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
|
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
`INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
VALUES ${tuples.join(', ')}
|
VALUES ${tuples.join(', ')}
|
||||||
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
|
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
|
||||||
values
|
values
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { createHash } from 'node:crypto';
|
|||||||
import type { Pool as MariaPool } from 'mariadb';
|
import type { Pool as MariaPool } from 'mariadb';
|
||||||
import type { Pool as PgPool, PoolClient } from 'pg';
|
import type { Pool as PgPool, PoolClient } from 'pg';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isLegacyArchiveProfile,
|
||||||
|
normalizeArchivedGeneral,
|
||||||
|
type ArchivedGeneralSourceFormat,
|
||||||
|
type ArchivedJsonValue,
|
||||||
|
type LegacyArchiveProfile,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
paginateSource,
|
paginateSource,
|
||||||
jsonParameter,
|
jsonParameter,
|
||||||
@@ -29,6 +39,12 @@ import {
|
|||||||
|
|
||||||
const batchSize = 250;
|
const batchSize = 250;
|
||||||
|
|
||||||
|
interface ArchiveMigrationContext {
|
||||||
|
profile: LegacyArchiveProfile;
|
||||||
|
importRunId: string;
|
||||||
|
sourceFormats: Record<ArchivedGeneralSourceFormat, number>;
|
||||||
|
}
|
||||||
|
|
||||||
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
|
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
|
||||||
value === null || value === undefined ? fallback : parseJson(value, context);
|
value === null || value === undefined ? fallback : parseJson(value, context);
|
||||||
|
|
||||||
@@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => {
|
|||||||
return memberNo > 0 ? legacyUserId(memberNo) : null;
|
return memberNo > 0 ? legacyUserId(memberNo) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashYearbook = (row: TargetRow): string =>
|
const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string =>
|
||||||
createHash('sha256')
|
createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex');
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
const asJsonRecord = (value: JsonValue): Record<string, JsonValue> =>
|
||||||
map: row.map,
|
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
|
||||||
nations: row.nations,
|
|
||||||
globalHistory: row.global_history,
|
export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => {
|
||||||
globalAction: row.global_action,
|
const record = asJsonRecord(env);
|
||||||
})
|
const candidate = record.opentime ?? record.starttime;
|
||||||
)
|
return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context);
|
||||||
.digest('hex');
|
};
|
||||||
|
|
||||||
const migrateSimpleTable = async (
|
const migrateSimpleTable = async (
|
||||||
source: MariaPool,
|
source: MariaPool,
|
||||||
@@ -75,17 +91,24 @@ const migrateSimpleTable = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
const migrateHall = (
|
||||||
|
source: MariaPool,
|
||||||
|
target: PoolClient | null,
|
||||||
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
|
): Promise<void> =>
|
||||||
migrateSimpleTable(
|
migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'hall',
|
'hall',
|
||||||
'id',
|
'id',
|
||||||
'hall',
|
'legacy_archive.hall',
|
||||||
['server_id', 'type', 'general_no'],
|
['source_profile', 'server_id', 'type', 'general_no'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const sourceId = toNumber(row.id, 'hall.id');
|
const sourceId = toNumber(row.id, 'hall.id');
|
||||||
return {
|
return {
|
||||||
|
source_profile: archive.profile,
|
||||||
|
legacy_id: sourceId,
|
||||||
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
|
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
|
||||||
season: toNumber(row.season, `hall.${sourceId}.season`),
|
season: toNumber(row.season, `hall.${sourceId}.season`),
|
||||||
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
|
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
|
||||||
@@ -94,24 +117,36 @@ const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Recor
|
|||||||
value: toFloat(row.value, `hall.${sourceId}.value`),
|
value: toFloat(row.value, `hall.${sourceId}.value`),
|
||||||
owner: ownerId(row.owner),
|
owner: ownerId(row.owner),
|
||||||
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
|
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
|
||||||
|
import_run_id: archive.importRunId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
counts
|
counts
|
||||||
);
|
);
|
||||||
|
|
||||||
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
const migrateGames = (
|
||||||
|
source: MariaPool,
|
||||||
|
target: PoolClient | null,
|
||||||
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
|
): Promise<void> =>
|
||||||
migrateSimpleTable(
|
migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'ng_games',
|
'ng_games',
|
||||||
'id',
|
'id',
|
||||||
'ng_games',
|
'legacy_archive.game_history',
|
||||||
['server_id'],
|
['source_profile', 'server_id'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const sourceId = toNumber(row.id, 'ng_games.id');
|
const sourceId = toNumber(row.id, 'ng_games.id');
|
||||||
|
const legacyDate = toDate(row.date, `ng_games.${sourceId}.date`);
|
||||||
|
const env = parseJson(row.env, `ng_games.${sourceId}.env`);
|
||||||
return {
|
return {
|
||||||
|
source_profile: archive.profile,
|
||||||
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
|
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
|
||||||
date: toDate(row.date, `ng_games.${sourceId}.date`),
|
legacy_id: sourceId,
|
||||||
|
opened_at: resolveLegacyGameOpenedAt(env, legacyDate, `ng_games.${sourceId}.opened_at`),
|
||||||
|
completed_at: null,
|
||||||
|
legacy_date: legacyDate,
|
||||||
winner_nation:
|
winner_nation:
|
||||||
row.winner_nation === null
|
row.winner_nation === null
|
||||||
? null
|
? null
|
||||||
@@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
|||||||
season: toNumber(row.season, `ng_games.${sourceId}.season`),
|
season: toNumber(row.season, `ng_games.${sourceId}.season`),
|
||||||
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
|
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
|
||||||
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
|
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
|
||||||
env: parseJson(row.env, `ng_games.${sourceId}.env`),
|
raw_env: jsonParameter(env),
|
||||||
|
import_run_id: archive.importRunId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
counts
|
counts
|
||||||
@@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
|||||||
const migrateOldGenerals = (
|
const migrateOldGenerals = (
|
||||||
source: MariaPool,
|
source: MariaPool,
|
||||||
target: PoolClient | null,
|
target: PoolClient | null,
|
||||||
counts: Record<string, number>
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
migrateSimpleTable(
|
migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'ng_old_generals',
|
'ng_old_generals',
|
||||||
'id',
|
'id',
|
||||||
'ng_old_generals',
|
'legacy_archive.general',
|
||||||
['server_id', 'general_no'],
|
['source_profile', 'server_id', 'general_no'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const sourceId = toNumber(row.id, 'ng_old_generals.id');
|
const sourceId = toNumber(row.id, 'ng_old_generals.id');
|
||||||
|
const name = toStringValue(row.name, `ng_old_generals.${sourceId}.name`);
|
||||||
|
const rawData = parseJson(row.data, `ng_old_generals.${sourceId}.data`);
|
||||||
|
const normalized = normalizeArchivedGeneral(rawData as ArchivedJsonValue, name);
|
||||||
|
archive.sourceFormats[normalized.sourceFormat] += 1;
|
||||||
return {
|
return {
|
||||||
|
source_profile: archive.profile,
|
||||||
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
|
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
|
||||||
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
|
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
|
||||||
|
legacy_id: sourceId,
|
||||||
owner: ownerId(row.owner),
|
owner: ownerId(row.owner),
|
||||||
name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`),
|
name,
|
||||||
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
|
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
|
||||||
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
|
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
|
||||||
data: parseJson(row.data, `ng_old_generals.${sourceId}.data`),
|
schema_version: normalized.snapshot.schemaVersion,
|
||||||
|
source_format: normalized.sourceFormat,
|
||||||
|
data: jsonParameter(normalized.snapshot),
|
||||||
|
raw_data: jsonParameter(rawData),
|
||||||
|
import_run_id: archive.importRunId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
counts
|
counts
|
||||||
@@ -156,41 +203,47 @@ const migrateOldGenerals = (
|
|||||||
const migrateOldNations = (
|
const migrateOldNations = (
|
||||||
source: MariaPool,
|
source: MariaPool,
|
||||||
target: PoolClient | null,
|
target: PoolClient | null,
|
||||||
counts: Record<string, number>
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
migrateSimpleTable(
|
migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'ng_old_nations',
|
'ng_old_nations',
|
||||||
'id',
|
'id',
|
||||||
'ng_old_nations',
|
'legacy_archive.nation',
|
||||||
['server_id', 'nation', 'source_id'],
|
['source_profile', 'legacy_id'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const sourceId = toNumber(row.id, 'ng_old_nations.id');
|
const sourceId = toNumber(row.id, 'ng_old_nations.id');
|
||||||
return {
|
return {
|
||||||
|
source_profile: archive.profile,
|
||||||
|
legacy_id: sourceId,
|
||||||
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
|
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
|
||||||
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
|
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
|
||||||
source_id: sourceId,
|
data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)),
|
||||||
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`),
|
archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
||||||
date: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
import_run_id: archive.importRunId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
counts
|
counts
|
||||||
);
|
);
|
||||||
|
|
||||||
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
const migrateEmperors = (
|
||||||
|
source: MariaPool,
|
||||||
|
target: PoolClient | null,
|
||||||
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
|
): Promise<void> =>
|
||||||
migrateSimpleTable(
|
migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'emperior',
|
'emperior',
|
||||||
'no',
|
'no',
|
||||||
'emperior',
|
'legacy_archive.emperor',
|
||||||
['legacy_id'],
|
['source_profile', 'legacy_id'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const id = toNumber(row.no, 'emperior.no');
|
const id = toNumber(row.no, 'emperior.no');
|
||||||
return {
|
const data = {
|
||||||
legacy_id: id,
|
|
||||||
server_id: toNullableString(row.server_id),
|
|
||||||
phase: toNullableString(row.phase),
|
phase: toNullableString(row.phase),
|
||||||
nation_count: toNullableString(row.nation_count),
|
nation_count: toNullableString(row.nation_count),
|
||||||
nation_name: toNullableString(row.nation_name),
|
nation_name: toNullableString(row.nation_name),
|
||||||
@@ -232,6 +285,13 @@ const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: R
|
|||||||
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
|
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
|
||||||
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
|
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
source_profile: archive.profile,
|
||||||
|
legacy_id: id,
|
||||||
|
server_id: toNullableString(row.server_id),
|
||||||
|
data: jsonParameter(data),
|
||||||
|
import_run_id: archive.importRunId,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
counts
|
counts
|
||||||
);
|
);
|
||||||
@@ -295,30 +355,35 @@ const migrateUserRecords = (
|
|||||||
const migrateYearbook = async (
|
const migrateYearbook = async (
|
||||||
source: MariaPool,
|
source: MariaPool,
|
||||||
target: PoolClient | null,
|
target: PoolClient | null,
|
||||||
counts: Record<string, number>
|
counts: Record<string, number>,
|
||||||
|
archive: ArchiveMigrationContext
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await migrateSimpleTable(
|
await migrateSimpleTable(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
'ng_history',
|
'ng_history',
|
||||||
'no',
|
'no',
|
||||||
'yearbook_history',
|
'legacy_archive.yearbook',
|
||||||
['profile_name', 'year', 'month', 'source_id'],
|
['source_profile', 'legacy_id'],
|
||||||
(row) => {
|
(row) => {
|
||||||
const id = toNumber(row.no, 'ng_history.no');
|
const id = toNumber(row.no, 'ng_history.no');
|
||||||
|
const map = parseNullableJson(row.map, {}, `ng_history.${id}.map`);
|
||||||
|
const nations = parseNullableJson(row.nations, [], `ng_history.${id}.nations`);
|
||||||
|
const globalHistory = parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`);
|
||||||
|
const globalAction = parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`);
|
||||||
const mapped: TargetRow = {
|
const mapped: TargetRow = {
|
||||||
|
source_profile: archive.profile,
|
||||||
|
legacy_id: id,
|
||||||
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
|
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
|
||||||
source_id: id,
|
|
||||||
year: toNumber(row.year, `ng_history.${id}.year`),
|
year: toNumber(row.year, `ng_history.${id}.year`),
|
||||||
month: toNumber(row.month, `ng_history.${id}.month`),
|
month: toNumber(row.month, `ng_history.${id}.month`),
|
||||||
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`),
|
map: jsonParameter(map),
|
||||||
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`),
|
nations: jsonParameter(nations),
|
||||||
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`),
|
global_history: jsonParameter(globalHistory),
|
||||||
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`),
|
global_action: jsonParameter(globalAction),
|
||||||
hash: '',
|
content_hash: hashYearbook(map, nations, globalHistory, globalAction),
|
||||||
created_at: new Date(0),
|
import_run_id: archive.importRunId,
|
||||||
};
|
};
|
||||||
mapped.hash = hashYearbook(mapped);
|
|
||||||
return mapped;
|
return mapped;
|
||||||
},
|
},
|
||||||
counts,
|
counts,
|
||||||
@@ -388,7 +453,16 @@ export const migrateGame = async (
|
|||||||
apply: boolean,
|
apply: boolean,
|
||||||
profile: string
|
profile: string
|
||||||
): Promise<MigrationSummary> => {
|
): Promise<MigrationSummary> => {
|
||||||
|
if (!isLegacyArchiveProfile(profile)) {
|
||||||
|
throw new Error(`Unsupported legacy archive profile: ${profile}`);
|
||||||
|
}
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
|
const sourceFormats: Record<ArchivedGeneralSourceFormat, number> = {
|
||||||
|
'legacy-flat-v0': 0,
|
||||||
|
'ref-flat-v1': 0,
|
||||||
|
'core-snapshot-v1': 0,
|
||||||
|
unknown: 0,
|
||||||
|
};
|
||||||
const excluded = {
|
const excluded = {
|
||||||
general: 'Current-season actor state is intentionally not transferred.',
|
general: 'Current-season actor state is intentionally not transferred.',
|
||||||
city: 'Current-season world state is intentionally not transferred.',
|
city: 'Current-season world state is intentionally not transferred.',
|
||||||
@@ -421,25 +495,59 @@ export const migrateGame = async (
|
|||||||
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
|
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
|
||||||
};
|
};
|
||||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
const client = apply && targetPool ? await targetPool.connect() : null;
|
||||||
|
let importRunId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const run = async (): Promise<void> => {
|
const run = async (archive: ArchiveMigrationContext): Promise<void> => {
|
||||||
await migrateGames(source, client, counts);
|
await migrateGames(source, client, counts, archive);
|
||||||
await migrateHall(source, client, counts);
|
await migrateHall(source, client, counts, archive);
|
||||||
await migrateOldGenerals(source, client, counts);
|
await migrateOldGenerals(source, client, counts, archive);
|
||||||
await migrateOldNations(source, client, counts);
|
await migrateOldNations(source, client, counts, archive);
|
||||||
await migrateEmperors(source, client, counts);
|
await migrateEmperors(source, client, counts, archive);
|
||||||
await migrateInheritanceResults(source, client, counts);
|
await migrateInheritanceResults(source, client, counts);
|
||||||
await migrateUserRecords(source, client, counts);
|
await migrateUserRecords(source, client, counts);
|
||||||
await migrateStorage(source, client, counts);
|
await migrateStorage(source, client, counts);
|
||||||
await migrateYearbook(source, client, counts);
|
await migrateYearbook(source, client, counts, archive);
|
||||||
};
|
};
|
||||||
if (client) {
|
if (client) {
|
||||||
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
|
await withMigrationLock(client, `sammo-legacy-archive-v2:${profile}`, async () => {
|
||||||
|
const created = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO "legacy_archive"."import_run" ("source_profile", "status")
|
||||||
|
VALUES ($1, 'RUNNING') RETURNING "id"`,
|
||||||
|
[profile]
|
||||||
|
);
|
||||||
|
importRunId = created.rows[0]?.id ?? null;
|
||||||
|
if (!importRunId) throw new Error('Failed to create legacy archive import run');
|
||||||
|
const archive: ArchiveMigrationContext = { profile, importRunId, sourceFormats };
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
await run(archive);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "legacy_archive"."import_run"
|
||||||
|
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
|
||||||
|
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats)]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "legacy_archive"."import_run"
|
||||||
|
SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP,
|
||||||
|
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb, "error" = $4
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats), message]
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await run();
|
await run({ profile, importRunId: '0', sourceFormats });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
client?.release();
|
client?.release();
|
||||||
}
|
}
|
||||||
return { command: 'game', apply, counts, excluded };
|
return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,17 +24,83 @@ export interface MigrationSummary {
|
|||||||
apply: boolean;
|
apply: boolean;
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
excluded: Record<string, string>;
|
excluded: Record<string, string>;
|
||||||
|
importRunId?: string | null;
|
||||||
|
sourceFormatSummary?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSize = 500;
|
const batchSize = 500;
|
||||||
|
|
||||||
const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
export const MEMBER_PRESERVED_COLUMNS = [
|
||||||
|
'login_id',
|
||||||
|
'display_name',
|
||||||
|
'password_hash',
|
||||||
|
'password_salt',
|
||||||
|
'password_reset_required',
|
||||||
|
'roles',
|
||||||
|
'sanctions',
|
||||||
|
'oauth_type',
|
||||||
|
'oauth_id',
|
||||||
|
'email',
|
||||||
|
'oauth_info',
|
||||||
|
'picture',
|
||||||
|
'image_server',
|
||||||
|
'icon_updated_at',
|
||||||
|
'third_party_use',
|
||||||
|
'terms_accepted_at',
|
||||||
|
'privacy_accepted_at',
|
||||||
|
'kakao_verified_at',
|
||||||
|
'kakao_talk_verified_until',
|
||||||
|
'kakao_grace_started_at',
|
||||||
|
'delete_after',
|
||||||
|
'updated_at',
|
||||||
|
'last_login_at',
|
||||||
|
'created_at',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const preflightMemberConflicts = async (target: PoolClient, rows: readonly TargetRow[]): Promise<void> => {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const ids = rows.map((row) => String(row.id));
|
||||||
|
const loginIds = rows.map((row) => String(row.login_id));
|
||||||
|
const displayNames = rows.map((row) => String(row.display_name));
|
||||||
|
const emails = rows.map((row) => row.email).filter((value): value is string => typeof value === 'string');
|
||||||
|
const existing = await target.query<{
|
||||||
|
id: string;
|
||||||
|
login_id: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT "id", "login_id", "display_name", "email"
|
||||||
|
FROM "app_user"
|
||||||
|
WHERE "id" = ANY($1::text[])
|
||||||
|
OR "login_id" = ANY($2::text[])
|
||||||
|
OR "display_name" = ANY($3::text[])
|
||||||
|
OR "email" = ANY($4::text[])`,
|
||||||
|
[ids, loginIds, displayNames, emails]
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = String(row.id);
|
||||||
|
const collision = existing.rows.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id !== id &&
|
||||||
|
(candidate.login_id === row.login_id ||
|
||||||
|
candidate.display_name === row.display_name ||
|
||||||
|
(row.email !== null && candidate.email === row.email))
|
||||||
|
);
|
||||||
|
if (collision) {
|
||||||
|
throw new Error('Target account identity collision in legacy member batch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||||
const memberNo = toNumber(row.NO, 'member.NO');
|
const memberNo = toNumber(row.NO, 'member.NO');
|
||||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||||
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
|
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
|
||||||
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
|
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
|
||||||
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
|
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
|
||||||
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
|
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
|
||||||
|
const oauthId = toNullableString(row.oauth_id)?.trim() || null;
|
||||||
|
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
|
||||||
const legacyData: JsonValue = {
|
const legacyData: JsonValue = {
|
||||||
memberNo,
|
memberNo,
|
||||||
grade,
|
grade,
|
||||||
@@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
|||||||
id: legacyUserId(memberNo),
|
id: legacyUserId(memberNo),
|
||||||
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
|
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
|
||||||
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
|
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
|
||||||
password_hash: toStringValue(row.PW, `member.${memberNo}.PW`),
|
password_hash: passwordHash,
|
||||||
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
|
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
|
||||||
|
password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash),
|
||||||
roles: jsonParameter(mapLegacyRoles(grade, acl)),
|
roles: jsonParameter(mapLegacyRoles(grade, acl)),
|
||||||
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
|
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
|
||||||
oauth_type: oauthType,
|
oauth_type: oauthType,
|
||||||
oauth_id: toNullableString(row.oauth_id),
|
oauth_id: oauthId,
|
||||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||||
oauth_info: jsonParameter(oauthInfo),
|
oauth_info: jsonParameter(oauthInfo),
|
||||||
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
||||||
@@ -63,9 +130,9 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
|||||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||||
terms_accepted_at: null,
|
terms_accepted_at: null,
|
||||||
privacy_accepted_at: null,
|
privacy_accepted_at: null,
|
||||||
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
|
kakao_verified_at: oauthType === 'KAKAO' && oauthId ? migratedAt : null,
|
||||||
kakao_talk_verified_until:
|
kakao_talk_verified_until:
|
||||||
oauthType === 'KAKAO'
|
oauthType === 'KAKAO' && oauthId
|
||||||
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
|
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
|
||||||
: null,
|
: null,
|
||||||
kakao_grace_started_at: migratedAt,
|
kakao_grace_started_at: migratedAt,
|
||||||
@@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> =>
|
|||||||
const processMembers = async (
|
const processMembers = async (
|
||||||
source: MariaPool,
|
source: MariaPool,
|
||||||
target: PoolClient | null,
|
target: PoolClient | null,
|
||||||
|
apply: boolean,
|
||||||
migratedAt: Date,
|
migratedAt: Date,
|
||||||
counts: Record<string, number>
|
counts: Record<string, number>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@@ -103,7 +171,10 @@ const processMembers = async (
|
|||||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
||||||
});
|
});
|
||||||
if (target) {
|
if (target) {
|
||||||
await upsertRows(target, 'app_user', mapped, ['id']);
|
await preflightMemberConflicts(target, mapped);
|
||||||
|
}
|
||||||
|
if (target && apply) {
|
||||||
|
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
|
||||||
}
|
}
|
||||||
counts.member = (counts.member ?? 0) + mapped.length;
|
counts.member = (counts.member ?? 0) + mapped.length;
|
||||||
}
|
}
|
||||||
@@ -206,17 +277,26 @@ export const migrateGateway = async (
|
|||||||
login_token:
|
login_token:
|
||||||
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
|
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
|
||||||
};
|
};
|
||||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
const client = targetPool ? await targetPool.connect() : null;
|
||||||
try {
|
try {
|
||||||
const run = async (): Promise<void> => {
|
const run = async (): Promise<void> => {
|
||||||
await processMembers(source, client, migratedAt, counts);
|
await processMembers(source, client, apply, migratedAt, counts);
|
||||||
await processMemberLogs(source, client, counts);
|
await processMemberLogs(source, apply ? client : null, counts);
|
||||||
await processBannedMembers(source, client, counts);
|
await processBannedMembers(source, apply ? client : null, counts);
|
||||||
await processRootKeyValues(source, client, counts);
|
await processRootKeyValues(source, apply ? client : null, counts);
|
||||||
await processSystem(source, client, counts);
|
await processSystem(source, apply ? client : null, counts);
|
||||||
};
|
};
|
||||||
if (client) {
|
if (client && apply) {
|
||||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
|
await withMigrationLock(client, 'sammo-legacy-gateway-v1', async () => {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await run();
|
await run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
const fixture = async (name: string): Promise<ArchivedJsonValue> =>
|
||||||
|
JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue;
|
||||||
|
|
||||||
|
describe('normalizeArchivedGeneral', () => {
|
||||||
|
it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => {
|
||||||
|
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||||
|
await fixture('che-old-general-legacy-flat-v0.json'),
|
||||||
|
'fallback'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceFormat).toBe('legacy-flat-v0');
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
schemaVersion: 1,
|
||||||
|
identity: { name: '구형테스트장수', nationId: 3 },
|
||||||
|
stats: { leadership: 81, strength: 73, intelligence: 66 },
|
||||||
|
mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 },
|
||||||
|
battle: {
|
||||||
|
battles: 20,
|
||||||
|
wins: 12,
|
||||||
|
losses: 8,
|
||||||
|
winRate: 60,
|
||||||
|
killRate: 125,
|
||||||
|
tactics: { total: { wins: 3, draws: 1, losses: 2 } },
|
||||||
|
},
|
||||||
|
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||||
|
availability: { mastery: true, battleAggregates: true, tactics: true },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => {
|
||||||
|
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||||
|
await fixture('hwe-old-general-ref-flat-v1.json'),
|
||||||
|
'fallback'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sourceFormat).toBe('ref-flat-v1');
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
identity: { name: '신형테스트장수', officerLevel: 7 },
|
||||||
|
stats: {
|
||||||
|
leadership: 91,
|
||||||
|
strength: 82,
|
||||||
|
intelligence: 74,
|
||||||
|
leadershipExperience: 11,
|
||||||
|
},
|
||||||
|
traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' },
|
||||||
|
mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 },
|
||||||
|
battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null },
|
||||||
|
availability: { mastery: true, battleAggregates: false, tactics: false },
|
||||||
|
});
|
||||||
|
expect(snapshot.availability.battleDetailLogs).toBe(false);
|
||||||
|
expect(snapshot.availability.battleResultLogs).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an already-normalized version 1 snapshot stable', async () => {
|
||||||
|
const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback');
|
||||||
|
const second = normalizeArchivedGeneral(
|
||||||
|
first.snapshot as unknown as ArchivedJsonValue,
|
||||||
|
first.snapshot.identity.name
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(second.sourceFormat).toBe('core-snapshot-v1');
|
||||||
|
expect(second.snapshot).toEqual(first.snapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js';
|
||||||
|
|
||||||
|
describe('qualified archive identifiers', () => {
|
||||||
|
it('quotes a schema-qualified table and still parameterizes values', async () => {
|
||||||
|
const query = vi.fn().mockResolvedValue({});
|
||||||
|
await upsertRows(
|
||||||
|
{ query } as unknown as PoolClient,
|
||||||
|
'legacy_archive.general',
|
||||||
|
[{ id: 1, data: { ok: true } }],
|
||||||
|
['id']
|
||||||
|
);
|
||||||
|
expect(query).toHaveBeenCalledOnce();
|
||||||
|
expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"');
|
||||||
|
expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])(
|
||||||
|
'rejects unsafe qualified identifier %s',
|
||||||
|
(value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier')
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "구형테스트장수",
|
||||||
|
"leader": 81,
|
||||||
|
"power": 73,
|
||||||
|
"intel": 66,
|
||||||
|
"leader2": 4,
|
||||||
|
"power2": 5,
|
||||||
|
"intel2": 6,
|
||||||
|
"nation": 3,
|
||||||
|
"city": 7,
|
||||||
|
"level": 8,
|
||||||
|
"personal": 2,
|
||||||
|
"special": 4,
|
||||||
|
"special2": 5,
|
||||||
|
"experience": 12345,
|
||||||
|
"explevel": 8,
|
||||||
|
"dedication": 765,
|
||||||
|
"dedlevel": 4,
|
||||||
|
"dex0": 101,
|
||||||
|
"dex10": 202,
|
||||||
|
"dex20": 303,
|
||||||
|
"dex30": 404,
|
||||||
|
"dex40": 505,
|
||||||
|
"warnum": 20,
|
||||||
|
"killnum": 12,
|
||||||
|
"deathnum": 8,
|
||||||
|
"firenum": 7,
|
||||||
|
"killcrew": 2500,
|
||||||
|
"deathcrew": 2000,
|
||||||
|
"ttw": 3,
|
||||||
|
"ttd": 1,
|
||||||
|
"ttl": 2,
|
||||||
|
"tlw": 4,
|
||||||
|
"tld": 0,
|
||||||
|
"tll": 1,
|
||||||
|
"tiw": 5,
|
||||||
|
"tid": 2,
|
||||||
|
"til": 3,
|
||||||
|
"picture": "default.jpg",
|
||||||
|
"imgsvr": 0,
|
||||||
|
"horse": 1,
|
||||||
|
"weap": 2,
|
||||||
|
"book": 3,
|
||||||
|
"item": 4,
|
||||||
|
"history": "<C>●</>첫 기록<br><Y>●</>둘째 기록<br>",
|
||||||
|
"recent_war": "2020-01-02 03:04:05",
|
||||||
|
"ip": "192.0.2.1",
|
||||||
|
"lastconnect": "2020-01-02 03:04:05",
|
||||||
|
"refresh": 10
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "신형테스트장수",
|
||||||
|
"leadership": 91,
|
||||||
|
"strength": 82,
|
||||||
|
"intel": 74,
|
||||||
|
"leadership_exp": 11,
|
||||||
|
"strength_exp": 12,
|
||||||
|
"intel_exp": 13,
|
||||||
|
"nation": 4,
|
||||||
|
"city": 8,
|
||||||
|
"officer_level": 7,
|
||||||
|
"officer_city": 8,
|
||||||
|
"personal": "che_의리",
|
||||||
|
"special": "che_상재",
|
||||||
|
"special2": "che_신산",
|
||||||
|
"experience": 22222,
|
||||||
|
"explevel": 9,
|
||||||
|
"dedication": 999,
|
||||||
|
"dedlevel": 5,
|
||||||
|
"dex1": 111,
|
||||||
|
"dex2": 222,
|
||||||
|
"dex3": 333,
|
||||||
|
"dex4": 444,
|
||||||
|
"dex5": 555,
|
||||||
|
"picture": "default.jpg",
|
||||||
|
"imgsvr": 1,
|
||||||
|
"horse": "che_적토마",
|
||||||
|
"weapon": "che_청룡언월도",
|
||||||
|
"book": null,
|
||||||
|
"item": null,
|
||||||
|
"history": ["<C>●</>최신 기록", "<Y>●</>이전 기록"],
|
||||||
|
"recent_war": null,
|
||||||
|
"aux": "",
|
||||||
|
"ip": "198.51.100.1",
|
||||||
|
"lastconnect": "2026-01-02 03:04:05"
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Pool as MariaPool } from 'mariadb';
|
||||||
|
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js';
|
||||||
|
|
||||||
|
const sourceRows = {
|
||||||
|
ng_games: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
server_id: 'che_fixture_001',
|
||||||
|
date: new Date('2020-01-01T00:00:00.000Z'),
|
||||||
|
winner_nation: 3,
|
||||||
|
map: 'che',
|
||||||
|
season: 1,
|
||||||
|
scenario: 2,
|
||||||
|
scenario_name: 'fixture',
|
||||||
|
env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ng_old_generals: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
server_id: 'che_fixture_001',
|
||||||
|
general_no: 10,
|
||||||
|
owner: 42,
|
||||||
|
name: 'fixture-general',
|
||||||
|
last_yearmonth: 22012,
|
||||||
|
turntime: new Date('2020-02-01T00:00:00.000Z'),
|
||||||
|
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Record<string, Array<Record<string, unknown>>>;
|
||||||
|
|
||||||
|
const sourcePool = (): MariaPool => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return {
|
||||||
|
query: vi.fn(async (sql: string) => {
|
||||||
|
const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? '';
|
||||||
|
if (seen.has(table)) return [];
|
||||||
|
seen.add(table);
|
||||||
|
return sourceRows[table as keyof typeof sourceRows] ?? [];
|
||||||
|
}),
|
||||||
|
} as unknown as MariaPool;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPool = (failPattern?: string) => {
|
||||||
|
const queries: Array<{ sql: string; values: readonly unknown[] }> = [];
|
||||||
|
const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => {
|
||||||
|
queries.push({ sql, values });
|
||||||
|
if (failPattern && sql.includes(failPattern)) {
|
||||||
|
failPattern = undefined;
|
||||||
|
throw new Error('synthetic archive write failure');
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) {
|
||||||
|
return { rows: [{ id: '77' }], rowCount: 1 } as QueryResult<{ id: string }>;
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0 } as unknown as QueryResult;
|
||||||
|
});
|
||||||
|
const client = { query, release: vi.fn() } as unknown as PoolClient;
|
||||||
|
return {
|
||||||
|
pool: { connect: vi.fn(async () => client) } as unknown as PgPool,
|
||||||
|
queries,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('legacy archive game migration', () => {
|
||||||
|
it('uses the official profile allowlist and resolves the best opening timestamp', () => {
|
||||||
|
expect(isLegacyArchiveProfile('che')).toBe(true);
|
||||||
|
expect(isLegacyArchiveProfile('hwe')).toBe(true);
|
||||||
|
expect(isLegacyArchiveProfile('custom')).toBe(false);
|
||||||
|
expect(
|
||||||
|
resolveLegacyGameOpenedAt(
|
||||||
|
{ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' },
|
||||||
|
new Date('2020-01-01T00:00:00.000Z'),
|
||||||
|
'fixture'
|
||||||
|
).toISOString()
|
||||||
|
).toBe('2020-01-02T00:00:00.000Z');
|
||||||
|
expect(
|
||||||
|
resolveLegacyGameOpenedAt(
|
||||||
|
{ starttime: '2020-01-03T00:00:00.000Z' },
|
||||||
|
new Date('2020-01-01T00:00:00.000Z'),
|
||||||
|
'fixture'
|
||||||
|
).toISOString()
|
||||||
|
).toBe('2020-01-03T00:00:00.000Z');
|
||||||
|
expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe(
|
||||||
|
'2020-01-01T00:00:00.000Z'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dry-run target-read-only while reporting normalized formats', async () => {
|
||||||
|
const summary = await migrateGame(sourcePool(), null, false, 'che');
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
apply: false,
|
||||||
|
importRunId: null,
|
||||||
|
counts: { ng_games: 1, ng_old_generals: 1 },
|
||||||
|
sourceFormatSummary: { 'legacy-flat-v0': 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a completed import run and writes only archive tables for historical snapshots', async () => {
|
||||||
|
const target = targetPool();
|
||||||
|
const summary = await migrateGame(sourcePool(), target.pool, true, 'che');
|
||||||
|
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||||
|
|
||||||
|
expect(summary.importRunId).toBe('77');
|
||||||
|
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
|
||||||
|
expect(sql).toContain('INSERT INTO "legacy_archive"."general"');
|
||||||
|
expect(sql).not.toContain('INSERT INTO "ng_games"');
|
||||||
|
expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
|
||||||
|
expect(sql).toContain(`SET "status" = 'COMPLETED'`);
|
||||||
|
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
|
||||||
|
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
|
||||||
|
expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan(
|
||||||
|
target.queries.findIndex((entry) => entry.sql === 'COMMIT')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back archive writes and records a failed import run', async () => {
|
||||||
|
const target = targetPool('INSERT INTO "legacy_archive"."general"');
|
||||||
|
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||||
|
'synthetic archive write failure'
|
||||||
|
);
|
||||||
|
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||||
|
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||||
|
expect(sql).toContain(`SET "status" = 'FAILED'`);
|
||||||
|
expect(sql).not.toContain(`SET "status" = 'COMPLETED'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back archive writes when completing the import run fails', async () => {
|
||||||
|
const target = targetPool(`SET "status" = 'COMPLETED'`);
|
||||||
|
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||||
|
'synthetic archive write failure'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false);
|
||||||
|
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||||
|
expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsupported profile before reading or writing', async () => {
|
||||||
|
await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow(
|
||||||
|
'Unsupported legacy archive profile'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { upsertRows } from '../src/db.js';
|
||||||
|
import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js';
|
||||||
|
|
||||||
|
const memberRow = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
NO: 7,
|
||||||
|
GRADE: 1,
|
||||||
|
acl: '{}',
|
||||||
|
penalty: '{}',
|
||||||
|
oauth_info: '{}',
|
||||||
|
oauth_type: 'KAKAO',
|
||||||
|
oauth_id: null,
|
||||||
|
PW: 'a'.repeat(128),
|
||||||
|
salt: 'member-salt',
|
||||||
|
ID: 'LegacyUser',
|
||||||
|
NAME: '레거시유저',
|
||||||
|
EMAIL: 'USER@EXAMPLE.TEST',
|
||||||
|
PICTURE: 'default.jpg',
|
||||||
|
IMGSVR: 0,
|
||||||
|
third_use: 0,
|
||||||
|
token_valid_until: null,
|
||||||
|
delete_after: null,
|
||||||
|
REG_DATE: '2020-01-01 00:00:00',
|
||||||
|
REG_NUM: 0,
|
||||||
|
BLOCK_NUM: 0,
|
||||||
|
BLOCK_DATE: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy gateway member migration', () => {
|
||||||
|
it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => {
|
||||||
|
const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null);
|
||||||
|
|
||||||
|
expect(mapped).toMatchObject({
|
||||||
|
login_id: 'legacyuser',
|
||||||
|
email: 'user@example.test',
|
||||||
|
password_reset_required: true,
|
||||||
|
oauth_type: 'KAKAO',
|
||||||
|
oauth_id: null,
|
||||||
|
kakao_verified_at: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
|
||||||
|
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
|
||||||
|
const client = { query } as unknown as PoolClient;
|
||||||
|
|
||||||
|
await upsertRows(
|
||||||
|
client,
|
||||||
|
'app_user',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'legacy-id',
|
||||||
|
login_id: 'legacy-user',
|
||||||
|
password_hash: 'legacy-hash',
|
||||||
|
oauth_info: '{}',
|
||||||
|
legacy_data: '{}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['id'],
|
||||||
|
{ preserveOnConflict: ['password_hash', 'oauth_info'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const sql = String(query.mock.calls[0]?.[0]);
|
||||||
|
expect(sql).toContain('"login_id" = EXCLUDED."login_id"');
|
||||||
|
expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"');
|
||||||
|
expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"');
|
||||||
|
expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves renamed login and display identities along with every live credential field', () => {
|
||||||
|
expect(MEMBER_PRESERVED_COLUMNS).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'login_id',
|
||||||
|
'display_name',
|
||||||
|
'password_hash',
|
||||||
|
'password_salt',
|
||||||
|
'password_reset_required',
|
||||||
|
'roles',
|
||||||
|
'sanctions',
|
||||||
|
'oauth_id',
|
||||||
|
'email',
|
||||||
|
'updated_at',
|
||||||
|
'last_login_at',
|
||||||
|
'created_at',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a source member when another target account already owns its identity', async () => {
|
||||||
|
const query = vi.fn(async (..._args: unknown[]) => ({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'another-target-id',
|
||||||
|
login_id: 'legacyuser',
|
||||||
|
display_name: '다른사용자',
|
||||||
|
email: 'other@example.test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rowCount: 1,
|
||||||
|
}));
|
||||||
|
const client = { query } as unknown as PoolClient;
|
||||||
|
const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null);
|
||||||
|
|
||||||
|
await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow(
|
||||||
|
'Target account identity collision in legacy member batch'
|
||||||
|
);
|
||||||
|
expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user