fix(migration): 검증 실패 아이콘을 명시적으로 제외한다

검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다.
This commit is contained in:
2026-08-24 08:45:47 +00:00
parent c88a8de9bc
commit cd0f50f89d
9 changed files with 313 additions and 49 deletions
+27 -8
View File
@@ -31,9 +31,11 @@ import {
normalizeLegacyIconPicture,
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconPreparation,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
type RejectedLegacyUserIcon,
} from './legacyUserIcons.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
@@ -119,7 +121,8 @@ export const mapMember = (
row: SourceRow,
migratedAt: Date,
lastLoginAt: Date | null,
importedIcon?: PreparedLegacyUserIcon
importedIcon?: PreparedLegacyUserIcon,
rejectedIcon?: RejectedLegacyUserIcon
): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
@@ -139,6 +142,7 @@ export const mapMember = (
picture: rawPicture,
imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
@@ -157,8 +161,8 @@ export const mapMember = (
oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo),
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
image_server: importedIcon?.imageServer ?? imageServer,
picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null,
@@ -196,21 +200,24 @@ const processMembers = async (
apply: boolean,
migratedAt: Date,
counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
): Promise<void> => {
const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO');
const importedIcon = preparedIcons.get(memberNo);
const rejectedIcon = rejectedIcons.get(memberNo);
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
if (
(sourcePicture !== 'default.jpg' && !importedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
(sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
) {
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
}
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon);
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon, rejectedIcon);
});
if (target) {
await preflightMemberConflicts(target, mapped);
@@ -228,6 +235,17 @@ const processMembers = async (
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
const rejected = await syncRejectedUserIcons(
target,
rows
.map((row) => rejectedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is RejectedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_rejected_current_reset =
(counts.user_icon_rejected_current_reset ?? 0) + rejected.currentReset;
counts.user_icon_rejected_target_preserved =
(counts.user_icon_rejected_target_preserved ?? 0) + rejected.targetPreserved;
}
counts.member = (counts.member ?? 0) + mapped.length;
}
@@ -351,10 +369,11 @@ export const migrateGateway = async (
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
counts.user_icon_existing_upload = prepared.counts.existingUploads;
counts.user_icon_uploaded = prepared.counts.uploaded;
counts.user_icon_rejected = prepared.counts.rejected;
};
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
recordIconCounts(prepared);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
progress.member = {
strategy: 'rescan',
startAfterId: null,