From 901de7fec6079613be5553c5d7ba9778e31cc565 Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Mon, 22 Jun 2026 14:50:44 +0900 Subject: [PATCH 1/8] Remove legacy migration scripts --- migration/.gitignore | 3 - migration/migrate-agenthon.js | 265 ------------------ migration/migrate-static-attachments.js | 213 -------------- migration/migrate-update-posts.js | 357 ------------------------ 4 files changed, 838 deletions(-) delete mode 100644 migration/.gitignore delete mode 100644 migration/migrate-agenthon.js delete mode 100644 migration/migrate-static-attachments.js delete mode 100644 migration/migrate-update-posts.js diff --git a/migration/.gitignore b/migration/.gitignore deleted file mode 100644 index 73e0dc4b..00000000 --- a/migration/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -.env -reports/ \ No newline at end of file diff --git a/migration/migrate-agenthon.js b/migration/migrate-agenthon.js deleted file mode 100644 index b11e4e55..00000000 --- a/migration/migrate-agenthon.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * migrate-agenthon.js - * agenthon-interview.md 와 이미지를 Blob Storage + Cosmos DB로 마이그레이션 - * - * 실행: node migrate-agenthon.js [--dry-run] - */ -'use strict'; - -const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); - -// .env 직접 파싱 (dotenv v17 대화형 UI 우회) -function loadEnv(envPath) { - try { - const content = fs.readFileSync(envPath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx < 0) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); - if (key && !(key in process.env)) process.env[key] = val; - } - } catch (e) { - // .env 없으면 무시 (환경변수 직접 설정 케이스) - } -} -loadEnv(path.join(__dirname, '.env')); - -const { v4: uuidv4 } = require('uuid'); -const { CosmosClient } = require('@azure/cosmos'); -const { BlobServiceClient } = require('@azure/storage-blob'); -const { AzureCliCredential } = require('@azure/identity'); - -// ─── 설정 ────────────────────────────────────────────────────────────────── -const DRY_RUN = process.argv.includes('--dry-run') || process.env.DRY_RUN === 'true'; - -const REPO_ROOT = path.join(__dirname, '..'); -const MD_FILE = path.join(REPO_ROOT, 'Elevate.Web', 'src', 'content', 'agenthon', 'agenthon-interview.md'); -const IMAGES_DIR = path.join(REPO_ROOT, 'Elevate.Web', 'public', 'images', 'agenthon'); - -const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; -const COSMOS_DATABASE_NAME = process.env.COSMOS_DATABASE_NAME || 'elevate'; -const COSMOS_CONTAINER_NAME = process.env.COSMOS_CONTAINER_NAME || 'posts'; -const COSMOS_KEY = process.env.COSMOS_KEY; - -const STORAGE_ACCOUNT_NAME = process.env.STORAGE_ACCOUNT_NAME; -const STORAGE_CONTAINER_NAME = process.env.STORAGE_CONTAINER_NAME || 'images'; - -// ─── 유틸 ────────────────────────────────────────────────────────────────── -function log(msg) { console.log(`[migrate-agenthon] ${msg}`); } -function warn(msg) { console.warn(`[migrate-agenthon] ⚠️ ${msg}`); } - -function buildBlobPath() { - const now = new Date(); - const yyyy = now.getFullYear(); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const id = uuidv4().replace(/-/g, ''); - return `uploads/${yyyy}/${mm}/${id}`; -} - -// Cosmos DB 클라이언트 초기화 -function makeCosmosClient() { - if (COSMOS_KEY) { - return new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); - } - return new CosmosClient({ endpoint: COSMOS_ENDPOINT, aadCredentials: new AzureCliCredential() }); -} - -// Blob Storage 클라이언트 초기화 -function makeBlobClient() { - return new BlobServiceClient( - `https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net`, - new AzureCliCredential() - ); -} - -// ─── 마이그레이션 로직 ────────────────────────────────────────────────────── - -/** - * 이미지 파일을 Blob Storage에 업로드하고 blob URL 반환 - * @returns {Map} 로컬경로(e.g. /images/agenthon/1.jpg) → blob URL - */ -async function uploadImages(blobServiceClient) { - const containerClient = blobServiceClient.getContainerClient(STORAGE_CONTAINER_NAME); - const imageFiles = fs.readdirSync(IMAGES_DIR).filter(f => /\.(jpe?g|png|gif|webp)$/i.test(f)); - - log(`이미지 ${imageFiles.length}개 발견: ${imageFiles.join(', ')}`); - - const urlMap = new Map(); - - for (const file of imageFiles) { - const localPath = path.join(IMAGES_DIR, file); - const ext = path.extname(file).slice(1).toLowerCase(); - const contentType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' - : ext === 'png' ? 'image/png' - : ext === 'gif' ? 'image/gif' - : 'image/webp'; - - const blobPath = `${buildBlobPath()}.${ext}`; - const localKey = `/images/agenthon/${file}`; - - if (DRY_RUN) { - const fakeBlobUrl = `https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net/${STORAGE_CONTAINER_NAME}/${blobPath}`; - urlMap.set(localKey, fakeBlobUrl); - log(`[DRY-RUN] 업로드 건너뜀: ${localKey} → ${fakeBlobUrl}`); - continue; - } - - const blockBlobClient = containerClient.getBlockBlobClient(blobPath); - const data = fs.readFileSync(localPath); - - log(`업로드 중: ${file} (${Math.round(data.length / 1024)} KB) → ${blobPath}`); - await blockBlobClient.upload(data, data.length, { - blobHTTPHeaders: { blobContentType: contentType }, - }); - - const blobUrl = `https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net/${STORAGE_CONTAINER_NAME}/${blobPath}`; - urlMap.set(localKey, blobUrl); - log(`✅ 업로드 완료: ${localKey} → ${blobUrl}`); - } - - return urlMap; -} - -/** - * Markdown 내 로컬 이미지 경로를 Blob URL로 치환 - */ -function replaceImagePaths(markdown, urlMap) { - let result = markdown; - for (const [localPath, blobUrl] of urlMap.entries()) { - // Markdown image syntax: ![alt](/images/agenthon/N.jpg) - const escaped = localPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - result = result.replace(new RegExp(escaped, 'g'), blobUrl); - } - return result; -} - -/** - * Markdown에서 제목 추출 (첫 번째 # 헤딩) - */ -function extractTitle(markdown) { - const match = markdown.match(/^#\s+(.+)$/m); - return match ? match[1].trim() : 'Agenthon Interview'; -} - -/** - * 요약문 추출 (첫 번째 단락) - */ -function extractExcerpt(markdown) { - const lines = markdown.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!') && !trimmed.startsWith('---')) { - return trimmed.replace(/\*\*/g, '').slice(0, 200); - } - } - return ''; -} - -/** - * Cosmos DB에 post 도큐먼트 생성 - */ -async function createPost(container, contentMarkdown) { - const now = new Date().toISOString(); - const id = uuidv4(); - const slug = 'ai-education-agenthon-interview'; - const category = 'agenthon'; - - // 중복 slug 체크 - const { resources: existing } = await container.items.query({ - query: 'SELECT c.id FROM c WHERE c.slug = @slug AND c.category = @category', - parameters: [ - { name: '@slug', value: slug }, - { name: '@category', value: category }, - ], - }).fetchAll(); - - if (existing.length > 0) { - warn(`slug '${slug}'가 이미 존재합니다 (id: ${existing[0].id}). 건너뜁니다.`); - return null; - } - - const title = extractTitle(contentMarkdown); - const excerpt = extractExcerpt(contentMarkdown); - - const doc = { - id, - partitionKey: category, - documentType: 'post', - slug, - category, - title, - contentMarkdown, - excerpt, - status: 'published', - tags: ['agenthon', '인터뷰', 'AI', '교육', 'Copilot'], - series: null, - seriesOrder: null, - thumbnail: null, - publishedAt: now, - createdAt: now, - updatedAt: now, - }; - - if (DRY_RUN) { - log(`[DRY-RUN] Cosmos DB 저장 건너뜀. 도큐먼트 미리보기:`); - console.log(JSON.stringify({ ...doc, contentMarkdown: doc.contentMarkdown.slice(0, 100) + '...' }, null, 2)); - return doc; - } - - const { resource } = await container.items.create(doc); - log(`✅ Cosmos DB 저장 완료: id=${resource.id}, slug=${resource.slug}`); - return resource; -} - -// ─── 메인 ─────────────────────────────────────────────────────────────────── -async function main() { - log(DRY_RUN ? '🔍 DRY-RUN 모드로 실행합니다.' : '🚀 실제 마이그레이션을 시작합니다.'); - - // 전제 조건 확인 - if (!COSMOS_ENDPOINT) throw new Error('COSMOS_ENDPOINT 환경변수가 없습니다.'); - if (!STORAGE_ACCOUNT_NAME) throw new Error('STORAGE_ACCOUNT_NAME 환경변수가 없습니다.'); - if (!fs.existsSync(MD_FILE)) throw new Error(`MD 파일 없음: ${MD_FILE}`); - if (!fs.existsSync(IMAGES_DIR)) throw new Error(`이미지 폴더 없음: ${IMAGES_DIR}`); - - // 클라이언트 초기화 - const blobServiceClient = makeBlobClient(); - const cosmosClient = makeCosmosClient(); - const container = cosmosClient.database(COSMOS_DATABASE_NAME).container(COSMOS_CONTAINER_NAME); - - // 1. 이미지 업로드 - log('--- 1단계: 이미지 Blob Storage 업로드 ---'); - const urlMap = await uploadImages(blobServiceClient); - - // 2. Markdown 파싱 + 이미지 경로 치환 - log('--- 2단계: Markdown 이미지 경로 치환 ---'); - const rawMarkdown = fs.readFileSync(MD_FILE, 'utf-8'); - const processedMarkdown = replaceImagePaths(rawMarkdown, urlMap); - - const replacedCount = [...urlMap.keys()].filter(k => processedMarkdown.includes(urlMap.get(k))).length; - log(`이미지 경로 치환: ${replacedCount}/${urlMap.size}개`); - - // 3. Cosmos DB 게시글 저장 - log('--- 3단계: Cosmos DB 게시글 저장 ---'); - const post = await createPost(container, processedMarkdown); - - if (post) { - log(''); - log('✅ 마이그레이션 완료!'); - log(` - 이미지 업로드: ${urlMap.size}개`); - log(` - 게시글: category=${post.category}, slug=${post.slug}, status=${post.status}`); - if (!DRY_RUN) { - log(' - Admin에서 확인: https://white-sea-0567ed600.4.azurestaticapps.net/agenthon'); - log(' - Web에서 확인: https://purple-mud-005887500.7.azurestaticapps.net/agenthon'); - } - } -} - -main().catch(err => { - console.error('[migrate-agenthon] ❌ 오류:', err.message || err); - process.exit(1); -}); diff --git a/migration/migrate-static-attachments.js b/migration/migrate-static-attachments.js deleted file mode 100644 index 9be19946..00000000 --- a/migration/migrate-static-attachments.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * migrate-static-attachments.js - * Elevate.Web/public/attach/ 의 정적 첨부파일을 Azure Blob Storage attachments 컨테이너로 마이그레이션 - * - * 실행: - * node migrate-static-attachments.js [--dry-run] - * - * 환경변수 (또는 migration/.env): - * COSMOS_ENDPOINT https://{account}.documents.azure.com:443/ - * COSMOS_DATABASE_NAME elevate - * COSMOS_CONTAINER_NAME posts - * STORAGE_ACCOUNT_NAME stelvdevimiruajbu5bya - * STORAGE_ATTACH_CONTAINER_NAME attachments (기본값) - * - * 사전 조건: az login + az account set --subscription - */ -'use strict'; - -const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); - -// .env 직접 파싱 (dotenv v17 대화형 UI 우회) -function loadEnv(envPath) { - try { - const content = fs.readFileSync(envPath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx < 0) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); - if (key && !(key in process.env)) process.env[key] = val; - } - } catch { - // .env 없으면 무시 - } -} -loadEnv(path.join(__dirname, '.env')); - -const { CosmosClient } = require('@azure/cosmos'); -const { BlobServiceClient } = require('@azure/storage-blob'); -const { AzureCliCredential } = require('@azure/identity'); - -const DRY_RUN = process.argv.includes('--dry-run'); - -const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; -const COSMOS_DATABASE_NAME = process.env.COSMOS_DATABASE_NAME || 'elevate'; -const COSMOS_CONTAINER_NAME = process.env.COSMOS_CONTAINER_NAME || 'posts'; -const STORAGE_ACCOUNT_NAME = process.env.STORAGE_ACCOUNT_NAME; -const ATTACH_CONTAINER_NAME = process.env.STORAGE_ATTACH_CONTAINER_NAME || 'attachments'; - -const ATTACH_DIR = path.join(__dirname, '../Elevate.Web/public/attach'); - -const MIME_MAP = { - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - '.pdf': 'application/pdf', - '.csv': 'text/csv', - '.zip': 'application/zip', - '.xls': 'application/vnd.ms-excel', - '.doc': 'application/msword', -}; - -function createUuid() { - if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); - return crypto.randomBytes(16).toString('hex'); -} - -function generateBlobPath(fileName) { - const now = new Date(); - const yyyy = now.getUTCFullYear(); - const mm = String(now.getUTCMonth() + 1).padStart(2, '0'); - const ext = path.extname(fileName).toLowerCase(); - return `attach/${yyyy}/${mm}/${createUuid()}${ext}`; -} - -function collectFiles(dir, baseDir, results = []) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - collectFiles(full, baseDir, results); - } else { - const relativePath = '/attach/' + path.relative(baseDir, full).replace(/\\/g, '/'); - const ext = path.extname(entry.name).toLowerCase(); - if (MIME_MAP[ext]) { - results.push({ fullPath: full, relativePath, fileName: entry.name, ext }); - } - } - } - return results; -} - -async function main() { - if (!COSMOS_ENDPOINT) throw new Error('COSMOS_ENDPOINT 환경변수가 필요합니다.'); - if (!STORAGE_ACCOUNT_NAME) throw new Error('STORAGE_ACCOUNT_NAME 환경변수가 필요합니다.'); - - console.log(DRY_RUN ? '[DRY-RUN 모드]' : '[실제 실행 모드]'); - console.log(`첨부파일 디렉터리: ${ATTACH_DIR}`); - console.log(`대상 컨테이너: ${ATTACH_CONTAINER_NAME}`); - - const files = collectFiles(ATTACH_DIR, ATTACH_DIR); - if (files.length === 0) { - console.log('마이그레이션할 파일이 없습니다.'); - return; - } - - console.log(`\n파일 ${files.length}개 발견:`); - for (const f of files) console.log(` ${f.relativePath}`); - - const credential = new AzureCliCredential(); - const blobServiceClient = new BlobServiceClient( - `https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net`, - credential - ); - const containerClient = blobServiceClient.getContainerClient(ATTACH_CONTAINER_NAME); - - const cosmosClient = new CosmosClient({ endpoint: COSMOS_ENDPOINT, aadCredentials: credential }); - const container = cosmosClient.database(COSMOS_DATABASE_NAME).container(COSMOS_CONTAINER_NAME); - - // Step 1: 파일 업로드 + 매핑 기록 - const mapping = {}; // relativePath → blobUrl - const now = new Date().toISOString(); - - console.log('\n── Step 1: Blob 업로드 ──'); - for (const file of files) { - const contentType = MIME_MAP[file.ext]; - const blobPath = generateBlobPath(file.fileName); - const blobUrl = `https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net/${ATTACH_CONTAINER_NAME}/${blobPath}`; - - console.log(` ${file.relativePath}`); - console.log(` → ${blobUrl}`); - - if (!DRY_RUN) { - const blockBlobClient = containerClient.getBlockBlobClient(blobPath); - const fileBuffer = fs.readFileSync(file.fullPath); - await blockBlobClient.uploadData(fileBuffer, { - blobHTTPHeaders: { blobContentType: contentType }, - }); - - // Cosmos에 attach 메타데이터 저장 - const fileId = createUuid(); - await container.items.create({ - id: fileId, - documentType: 'attach', - category: '_attach', - partitionKey: '_attach', - postId: null, - blobUrl, - fileName: file.fileName, - contentType, - sizeBytes: fs.statSync(file.fullPath).size, - createdAt: now, - updatedAt: now, - migratedFrom: file.relativePath, - }); - } - - mapping[file.relativePath] = blobUrl; - } - - // Step 2: Cosmos 포스트 본문 URL 교체 - console.log('\n── Step 2: 포스트 본문 URL 교체 ──'); - const { resources: posts } = await container.items.query( - "SELECT * FROM c WHERE NOT IS_DEFINED(c.documentType) OR c.documentType = 'post'" - ).fetchAll(); - - let updatedCount = 0; - for (const post of posts) { - let content = post.contentMarkdown || ''; - let changed = false; - - for (const [origPath, blobUrl] of Object.entries(mapping)) { - if (content.includes(origPath)) { - content = content.split(origPath).join(blobUrl); - changed = true; - } - } - - if (changed) { - console.log(` 포스트 업데이트: ${post.id} (${post.title || post.slug || ''})`); - if (!DRY_RUN) { - const updated = { ...post, contentMarkdown: content, updatedAt: now }; - await container.item(post.id, post.partitionKey || post.category).replace(updated); - } - updatedCount++; - } - } - - // Step 3: 리포트 - console.log('\n── 마이그레이션 결과 ──'); - console.log(`업로드된 파일: ${files.length}개`); - console.log(`업데이트된 포스트: ${updatedCount}개`); - console.log('\n매핑 테이블:'); - for (const [orig, blob] of Object.entries(mapping)) { - console.log(` ${orig}`); - console.log(` → ${blob}`); - } - - if (DRY_RUN) { - console.log('\n[DRY-RUN] 실제 변경 없음. --dry-run 플래그를 제거하여 실행하세요.'); - } else { - console.log('\n마이그레이션 완료!'); - } -} - -main().catch((err) => { - console.error('[migrate-static-attachments] 오류:', err); - process.exit(1); -}); diff --git a/migration/migrate-update-posts.js b/migration/migrate-update-posts.js deleted file mode 100644 index 7fd15151..00000000 --- a/migration/migrate-update-posts.js +++ /dev/null @@ -1,357 +0,0 @@ -/** - * migrate-update-posts.js - * Elevate.Web/posts/update/*.md → Cosmos DB 마이그레이션 - * - * 이전 이력: 41개 마크다운 파일 이관 완료 - * 현재: 신규 3개 (26-04-06, 26-04-13, 26-04-20) 추가 마이그레이션용 - * - * 실행: - * node migrate-update-posts.js # dry-run - * node migrate-update-posts.js --apply # 실제 업로드 - * node migrate-update-posts.js --apply --allow-update # 덮어쓰기 - * - * 인증: Azure CLI (az account get-access-token) → type=aad&ver=1.0&sig= - * (COSMOS_KEY 방식 미사용 - disableLocalAuth: true) - * 의존성: gray-matter, marked (migration/node_modules), built-in https/crypto - */ -'use strict'; - -const path = require('path'); -const fs = require('fs'); -const https = require('https'); -const crypto = require('crypto'); -const { execSync } = require('child_process'); - -const NODE_MODULES = path.join(__dirname, 'node_modules'); -const matter = require(path.join(NODE_MODULES, 'gray-matter')); -const { marked } = require(path.join(NODE_MODULES, 'marked')); - -// ─── .env 로드 ────────────────────────────────────────────────────────────── -function loadEnv(envPath) { - try { - const content = fs.readFileSync(envPath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx < 0) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ''); - if (key && !(key in process.env)) process.env[key] = val; - } - } catch (_) {} -} -loadEnv(path.join(__dirname, '.env')); - -// ─── 설정 ─────────────────────────────────────────────────────────────────── -const APPLY = process.argv.includes('--apply'); -const ALLOW_UPDATE = process.argv.includes('--allow-update'); - -const REPO_ROOT = path.join(__dirname, '..'); -const SOURCE_DIR = path.join(REPO_ROOT, 'Elevate.Web', 'posts', 'update'); -const EXPECTED_COUNT = 3; - -const COSMOS_ENDPOINT = (process.env.COSMOS_ENDPOINT || '').replace(/\/$/, ''); -const COSMOS_DATABASE_NAME = process.env.COSMOS_DATABASE_NAME || 'elevate'; -const COSMOS_CONTAINER_NAME = process.env.COSMOS_CONTAINER_NAME || 'posts'; - -// ─── 유틸 ─────────────────────────────────────────────────────────────────── -function uuidv4() { return crypto.randomUUID(); } -function log(msg) { console.log(`[migrate-update-posts] ${msg}`); } -function warn(msg) { console.warn(`[migrate-update-posts] WARNING: ${msg}`); } - -// ─── Azure CLI AAD 토큰 ───────────────────────────────────────────────────── -let cachedToken = null; -let tokenExpiry = 0; - -function getAadToken() { - const now = Date.now(); - if (cachedToken && now < tokenExpiry - 60000) return cachedToken; - log('Azure CLI로 AAD 토큰 획득 중...'); - const result = execSync( - 'az account get-access-token --resource https://cosmos.azure.com --query "{token:accessToken,exp:expiresOn}" -o json', - { encoding: 'utf-8' } - ); - const parsed = JSON.parse(result.trim()); - cachedToken = parsed.token; - tokenExpiry = parsed.exp ? new Date(parsed.exp).getTime() : now + 3500000; - log(`토큰 획득 완료 (만료: ${new Date(tokenExpiry).toISOString()})`); - return cachedToken; -} - -/** Cosmos DB AAD 인증 헤더: type=aad&ver=1.0&sig= (URL 인코딩) */ -function buildAadAuthHeader(token) { - return encodeURIComponent(`type=aad&ver=1.0&sig=${token}`); -} - -// ─── Cosmos DB REST API ────────────────────────────────────────────────────── - -function httpsRequest(options, body, token) { - return new Promise((resolve, reject) => { - const headers = { - 'Authorization': buildAadAuthHeader(token), - 'x-ms-version': '2018-12-31', - 'x-ms-date': new Date().toUTCString(), - ...options.extraHeaders, - 'Content-Length': body ? Buffer.byteLength(body, 'utf-8') : 0, - }; - const req = https.request( - { hostname: new URL(COSMOS_ENDPOINT).hostname, port: 443, ...options, headers }, - (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - let parsed; - try { parsed = JSON.parse(data); } catch { parsed = data; } - if (res.statusCode >= 400) { - const msg = typeof parsed === 'object' ? JSON.stringify(parsed) : data; - const err = new Error(`HTTP ${res.statusCode}: ${msg}`); - err.statusCode = res.statusCode; - return reject(err); - } - resolve(parsed); - }); - } - ); - req.on('error', reject); - if (body) req.write(body, 'utf-8'); - req.end(); - }); -} - -const BASE = `/dbs/${COSMOS_DATABASE_NAME}/colls/${COSMOS_CONTAINER_NAME}`; - -async function cosmosQuery(query, parameters = []) { - const body = JSON.stringify({ query, parameters }); - const token = getAadToken(); - const result = await httpsRequest({ - path: `${BASE}/docs`, - method: 'POST', - extraHeaders: { - 'x-ms-documentdb-isquery': 'true', - 'x-ms-documentdb-query-enablecrosspartition': 'true', - 'Content-Type': 'application/query+json', - }, - }, body, token); - return result.Documents || []; -} - -async function cosmosCreate(doc) { - const body = JSON.stringify(doc); - const token = getAadToken(); - return httpsRequest({ - path: `${BASE}/docs`, - method: 'POST', - extraHeaders: { - 'x-ms-documentdb-partitionkey': JSON.stringify([doc.partitionKey]), - 'Content-Type': 'application/json', - }, - }, body, token); -} - -async function cosmosReplace(id, partitionKey, doc) { - const body = JSON.stringify(doc); - const token = getAadToken(); - return httpsRequest({ - path: `${BASE}/docs/${id}`, - method: 'PUT', - extraHeaders: { - 'x-ms-documentdb-partitionkey': JSON.stringify([partitionKey]), - 'Content-Type': 'application/json', - }, - }, body, token); -} - -// ─── 마크다운 파싱 ────────────────────────────────────────────────────────── - -function normalizeTag(tag) { return String(tag || '').trim().toLowerCase(); } - -function resolveTags(data) { - if (Array.isArray(data.tags)) return [...new Set(data.tags.map(normalizeTag).filter(Boolean))]; - if (typeof data.tags === 'string') return [...new Set(data.tags.split(',').map(normalizeTag).filter(Boolean))]; - if (typeof data.tag === 'string') return [...new Set(data.tag.split(',').map(normalizeTag).filter(Boolean))]; - return []; -} - -function toDeterministicIsoDate(value, sourcePath) { - if (value instanceof Date && !Number.isNaN(value.getTime())) - return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())).toISOString(); - const raw = String(value || '').trim(); - if (!raw) throw new Error(`Missing required date in ${sourcePath}`); - if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw}T00:00:00.000Z`; - const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date "${raw}" in ${sourcePath}`); - return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate())).toISOString(); -} - -function resolveThumbnail(data) { - if (data.youtube) return { url: `https://img.youtube.com/vi/${String(data.youtube).trim()}/hqdefault.jpg` }; - if (data.image && /^https?:\/\//i.test(String(data.image))) return { url: String(data.image).trim() }; - return null; -} - -function stripInlineMarkdown(text) { - return String(text || '') - .replace(/!\[[^\]]*]\([^)]+\)/g, ' ') - .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/__([^_]+)__/g, '$1') - .replace(/_([^_]+)_/g, '$1') - .replace(/~~([^~]+)~~/g, '$1') - .replace(/<[^>]+>/g, ' ') - .replace(/\|/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function createExcerpt(content) { - const lines = String(content || '').replace(/\r\n/g, '\n').split('\n'); - const blocks = [], cur = []; - for (const line of lines) { - const t = line.trim(); - if (!t) { if (cur.length) { blocks.push(cur.join(' ')); cur.length = 0; } continue; } - if (/^#{1,6}\s+/.test(t) || /^```/.test(t)) { if (cur.length) { blocks.push(cur.join(' ')); cur.length = 0; } continue; } - const n = stripInlineMarkdown(t.replace(/^[-*+]\s+/, '').replace(/^\d+\.\s+/, '').replace(/^>\s+/, '')); - if (n) cur.push(n); - } - if (cur.length) blocks.push(cur.join(' ')); - const e = (blocks.find(b => b.length >= 20) || blocks[0] || '').trim(); - return e.length > 180 ? `${e.slice(0, 177).trimEnd()}...` : e; -} - -function parseMarkdownFile(filePath) { - const raw = fs.readFileSync(filePath, 'utf-8'); - const { data, content } = matter(raw); - const slug = path.basename(filePath, path.extname(filePath)).trim(); - const title = String(data.title || '').trim(); - const category = String(data.category || '').trim().toLowerCase(); - const tags = resolveTags(data); - - if (!title) throw new Error(`Missing required title in ${filePath}`); - if (category !== 'update') throw new Error(`Expected category "update", got "${category}" in ${filePath}`); - if (!slug) throw new Error(`Cannot derive slug from ${filePath}`); - if (tags.length === 0) throw new Error(`Missing required tags in ${filePath}`); - - const publishedAt = toDeterministicIsoDate(data.date, filePath); - const normalizedContent = String(content || '').trim(); - const contentMarkdown = marked(normalizedContent); - const thumbnail = resolveThumbnail(data); - const excerpt = String(data.excerpt || '').trim() || createExcerpt(normalizedContent); - const youtube = data.youtube ? String(data.youtube).trim() : null; - const series = data.series ? String(data.series).trim() : null; - const seriesOrder = data.seriesOrder != null ? Number(data.seriesOrder) : null; - - return { slug, category: 'update', title, excerpt, tags, youtube, thumbnail, series, seriesOrder, publishedAt, contentMarkdown }; -} - -// ─── Cosmos 조회 / 빌드 ───────────────────────────────────────────────────── - -async function findExistingPost(slug) { - const results = await cosmosQuery( - `SELECT TOP 1 c.id, c.partitionKey, c.createdAt FROM c - WHERE (NOT IS_DEFINED(c.documentType) OR c.documentType = "post") - AND c.category = "update" AND c.slug = @slug`, - [{ name: '@slug', value: slug }] - ); - return results[0] || null; -} - -function buildDocument(entry, existing) { - return { - ...(existing || {}), - id: existing?.id || uuidv4(), - partitionKey: 'update', - documentType: 'post', - slug: entry.slug, - category: 'update', - title: entry.title, - excerpt: entry.excerpt, - contentMarkdown: entry.contentMarkdown, - tags: entry.tags, - series: entry.series, - seriesOrder: entry.seriesOrder, - thumbnail: entry.thumbnail, - youtube: entry.youtube, - status: 'published', - publishedAt: entry.publishedAt, - updatedAt: entry.publishedAt, - createdAt: existing?.createdAt || entry.publishedAt, - }; -} - -// ─── 메인 ─────────────────────────────────────────────────────────────────── -async function main() { - log(APPLY ? '실제 마이그레이션을 시작합니다.' : 'DRY-RUN 모드 (--apply 없이는 DB에 쓰지 않음).'); - if (ALLOW_UPDATE) log(' --allow-update: 기존 게시글도 덮어씁니다.'); - - if (!COSMOS_ENDPOINT) throw new Error('COSMOS_ENDPOINT 환경변수가 없습니다.'); - if (!fs.existsSync(SOURCE_DIR)) throw new Error(`소스 디렉터리 없음: ${SOURCE_DIR}`); - - const files = fs.readdirSync(SOURCE_DIR) - .filter(f => f.endsWith('.md')) - .map(f => path.join(SOURCE_DIR, f)) - .sort(); - - log(`마크다운 파일 ${files.length}개 발견 (예상: ${EXPECTED_COUNT}개)`); - if (files.length !== EXPECTED_COUNT) - throw new Error(`파일 수 불일치: 예상 ${EXPECTED_COUNT}개, 발견 ${files.length}개`); - - log('\n--- 파싱 ---'); - const entries = []; - for (const file of files) { - try { entries.push(parseMarkdownFile(file)); } - catch (err) { throw new Error(`파싱 실패 [${path.basename(file)}]: ${err.message}`); } - } - entries.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt) || a.slug.localeCompare(b.slug)); - log(`파싱 완료. 최신 5개:`); - entries.slice(0, 5).forEach(e => log(` - ${e.publishedAt.slice(0, 10)} | ${e.slug} | ${e.title}`)); - - if (!APPLY) { - log('\n✅ DRY-RUN 완료. 실제 업로드: node migrate-update-posts.js --apply'); - return; - } - - log('\n--- Cosmos DB 업로드 ---'); - const summary = { created: 0, updated: 0, skipped: 0, failed: 0 }; - - for (const entry of entries) { - try { - const existing = await findExistingPost(entry.slug); - - if (existing && !ALLOW_UPDATE) { - summary.skipped++; - log(`[skip] ${entry.slug}`); - continue; - } - - const doc = buildDocument(entry, existing); - - if (existing) { - await cosmosReplace(existing.id, existing.partitionKey || 'update', doc); - summary.updated++; - log(`[update] ${entry.slug}`); - } else { - await cosmosCreate(doc); - summary.created++; - log(`[create] ${entry.slug}`); - } - } catch (err) { - summary.failed++; - warn(`[fail] ${entry.slug}: ${err.message}`); - } - } - - log('\n✅ 마이그레이션 완료!'); - log(` 생성: ${summary.created}개`); - log(` 업데이트: ${summary.updated}개`); - log(` 건너뜀: ${summary.skipped}개`); - log(` 실패: ${summary.failed}개`); - if (summary.failed > 0) process.exitCode = 1; -} - -main().catch(err => { - console.error('[migrate-update-posts] ERROR:', err.message || err); - process.exit(1); -}); From d829c59e25003b11e31fa52e036bc9bc6e7e327a Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Mon, 22 Jun 2026 15:03:52 +0900 Subject: [PATCH 2/8] Fix activity video route parameter handling --- .../controllers/activityVideoController.js | 2 +- Elevate.Server/tests/activity-videos.test.js | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Elevate.Server/src/controllers/activityVideoController.js b/Elevate.Server/src/controllers/activityVideoController.js index add4223c..29686bf4 100644 --- a/Elevate.Server/src/controllers/activityVideoController.js +++ b/Elevate.Server/src/controllers/activityVideoController.js @@ -17,7 +17,7 @@ function isPlainObject(value) { } function getActivityVideoId(req) { - return req.params.activityVideoId || req.params.id; + return req.params.activityVideoId || req.params.activityvideoid || req.params.id; } function normalizeOptionalString(value) { diff --git a/Elevate.Server/tests/activity-videos.test.js b/Elevate.Server/tests/activity-videos.test.js index e2801128..b4c48f16 100644 --- a/Elevate.Server/tests/activity-videos.test.js +++ b/Elevate.Server/tests/activity-videos.test.js @@ -171,6 +171,33 @@ test('getAdminActivityVideoDetail returns activity video detail', async () => { }); }); +test('getAdminActivityVideoDetail accepts lowercased Azure route parameter name', async () => { + docs = [{ + id: 'video-1', + type: 'activityVideo', + partitionKey: 'activityVideo', + videoId: 'SfK1hajr5qY', + title: 'Title', + category: '행사', + year: '2026', + channel: 'Microsoft Korea', + sortOrder: 1, + status: 'published', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }]; + const res = makeRes(); + + await ctrl.getAdminActivityVideoDetail({ + params: { activityvideoid: 'video-1' }, + query: {}, + correlationId: 'x', + }, res); + + assert.equal(res.getStatus(), 200); + assert.equal(res.getBody().id, 'video-1'); +}); + test('getAdminActivityVideoDetail returns 404 for missing activity video', async () => { const res = makeRes(); @@ -246,6 +273,28 @@ test('createActivityVideo creates normalized draft video', async () => { assert.equal(docs[0].partitionKey, 'activityVideo'); }); +test('createActivityVideo preserves optional description and channel values', async () => { + const res = makeRes(); + + await ctrl.createActivityVideo({ + body: { + videoId: 'SfK1hajr5qY', + title: 'Title', + description: ' Description ', + category: '행사', + year: '2026', + channel: ' Custom Channel ', + }, + params: {}, + query: {}, + correlationId: 'x', + }, res); + + assert.equal(res.getStatus(), 201); + assert.equal(res.getBody().description, 'Description'); + assert.equal(res.getBody().channel, 'Custom Channel'); +}); + test('updateActivityVideo clears description and publishes video', async () => { docs = [{ id: 'video-1', @@ -388,3 +437,18 @@ test('deleteActivityVideo returns 204', async () => { assert.equal(res.getStatus(), 204); assert.deepEqual(deletedItem, { id: 'video-1', pk: 'activityVideo' }); }); + +test('deleteActivityVideo accepts lowercased Azure route parameter name', async () => { + docs = [{ id: 'video-1', type: 'activityVideo', partitionKey: 'activityVideo' }]; + const res = makeRes(); + + await ctrl.deleteActivityVideo({ + body: null, + params: { activityvideoid: 'video-1' }, + query: {}, + correlationId: 'x', + }, res); + + assert.equal(res.getStatus(), 204); + assert.deepEqual(deletedItem, { id: 'video-1', pk: 'activityVideo' }); +}); From 544c4a889b08cf180f60bfe64afceced5822a31c Mon Sep 17 00:00:00 2001 From: YoonKeumJae Date: Fri, 26 Jun 2026 14:38:48 +0900 Subject: [PATCH 3/8] fix: recover stale web chunks --- .github/workflows/deploy.yml | 48 +++++++++++ Elevate.Web/index.html | 3 + Elevate.Web/package.json | 4 +- Elevate.Web/scripts/test-build-id-source.mjs | 21 +++++ .../test-chunk-load-recovery-source.mjs | 19 +++++ .../src/components/common/ErrorBoundary.jsx | 7 ++ Elevate.Web/src/main.jsx | 5 +- Elevate.Web/src/services/chunkLoadRecovery.js | 79 +++++++++++++++++++ Elevate.Web/src/services/clarity.js | 13 +++ Elevate.Web/vite.config.js | 17 ++++ docs/DEPLOYMENT_AND_RUNBOOK.md | 60 ++++++++++++++ 11 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 Elevate.Web/scripts/test-build-id-source.mjs create mode 100644 Elevate.Web/scripts/test-chunk-load-recovery-source.mjs create mode 100644 Elevate.Web/src/services/chunkLoadRecovery.js create mode 100644 docs/DEPLOYMENT_AND_RUNBOOK.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8d405f28..7c5db52b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,7 @@ jobs: VITE_BOT_TOKEN_ENDPOINT: ${{ secrets.VITE_BOT_TOKEN_ENDPOINT }} VITE_CLARITY_ENABLED: ${{ vars.VITE_CLARITY_ENABLED || 'true' }} VITE_CLARITY_PROJECT_ID: ${{ secrets.VITE_CLARITY_PROJECT_ID }} + VITE_BUILD_ID: ${{ github.sha }} - name: Include CNAME run: cp ../CNAME dist/ @@ -75,3 +76,50 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + - name: Smoke test deployed pages and assets + shell: bash + run: | + set -euo pipefail + + base_url="https://microsoft-elevate.com" + routes=("/" "/m365" "/program-news") + + fetch_route() { + local url="$1" + local html_file="$2" + curl -sS -L -o "${html_file}" -w "%{http_code}" "${url}" + } + + for route in "${routes[@]}"; do + url="${base_url}${route}" + html_file="$(mktemp)" + status="" + + for attempt in 1 2 3; do + status="$(fetch_route "${url}" "${html_file}")" + if [[ "${status}" == "200" ]]; then + break + fi + sleep 10 + done + + if [[ "${status}" != "200" ]]; then + echo "::error::${url} returned HTTP ${status}" + exit 1 + fi + + mapfile -t assets < <(grep -Eo '/assets/[^"]+\.(js|css)' "${html_file}" | sort -u) + if [[ "${#assets[@]}" -eq 0 ]]; then + echo "::error::${url} did not reference built JS/CSS assets" + exit 1 + fi + + for asset in "${assets[@]}"; do + asset_status="$(curl -sS -L -o /dev/null -w "%{http_code}" "${base_url}${asset}")" + if [[ "${asset_status}" != "200" ]]; then + echo "::error::${url} references ${asset}, which returned HTTP ${asset_status}" + exit 1 + fi + done + done diff --git a/Elevate.Web/index.html b/Elevate.Web/index.html index 10b95f9e..70c1a06b 100644 --- a/Elevate.Web/index.html +++ b/Elevate.Web/index.html @@ -25,6 +25,9 @@
+ diff --git a/Elevate.Web/package.json b/Elevate.Web/package.json index 9289e19f..ef6d27f9 100644 --- a/Elevate.Web/package.json +++ b/Elevate.Web/package.json @@ -11,7 +11,9 @@ "check:seo": "node scripts/check-seo.mjs", "perf:lighthouse": "node scripts/perf-lighthouse.mjs", "generate:seo-routes": "node scripts/generate-seo-routes.mjs", - "generate-posts": "node scripts/generate-posts.js" + "generate-posts": "node scripts/generate-posts.js", + "test:chunk-recovery": "node scripts/test-chunk-load-recovery-source.mjs", + "test:build-id": "node scripts/test-build-id-source.mjs" }, "dependencies": { "@microsoft/clarity": "^1.0.2", diff --git a/Elevate.Web/scripts/test-build-id-source.mjs b/Elevate.Web/scripts/test-build-id-source.mjs new file mode 100644 index 00000000..ca3a76c7 --- /dev/null +++ b/Elevate.Web/scripts/test-build-id-source.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const viteSource = readFileSync(join(__dirname, '../vite.config.js'), 'utf8'); +const indexSource = readFileSync(join(__dirname, '../index.html'), 'utf8'); +const mainSource = readFileSync(join(__dirname, '../src/main.jsx'), 'utf8'); +const claritySource = readFileSync(join(__dirname, '../src/services/clarity.js'), 'utf8'); +const boundarySource = readFileSync(join(__dirname, '../src/components/common/ErrorBoundary.jsx'), 'utf8'); + +assert.match(viteSource, /VITE_BUILD_ID/); +assert.match(viteSource, /GITHUB_SHA/); +assert.match(viteSource, /__ELEVATE_BUILD_ID__/); +assert.match(viteSource, /transformIndexHtml/); +assert.match(indexSource, /window\.__BUILD_ID__ = "__ELEVATE_BUILD_ID__"/); +assert.match(mainSource, /setClarityTag\('build_id'/); +assert.match(claritySource, /trackClientDiagnostic/); +assert.match(boundarySource, /window\.__BUILD_ID__/); +assert.match(boundarySource, /trackClientDiagnostic\('render_error'/); diff --git a/Elevate.Web/scripts/test-chunk-load-recovery-source.mjs b/Elevate.Web/scripts/test-chunk-load-recovery-source.mjs new file mode 100644 index 00000000..2b8b4b77 --- /dev/null +++ b/Elevate.Web/scripts/test-chunk-load-recovery-source.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, '../src/services/chunkLoadRecovery.js'), 'utf8'); +const mainSource = readFileSync(join(__dirname, '../src/main.jsx'), 'utf8'); + +assert.match(source, /Failed to fetch dynamically imported module/); +assert.match(source, /Importing a module script failed/); +assert.match(source, /chunk-recovery-attempted/); +assert.match(source, /sessionStorage\.getItem/); +assert.match(source, /sessionStorage\.setItem/); +assert.match(source, /window\.location\.reload\(\)/); +assert.match(source, /window\.addEventListener\('error'/); +assert.match(source, /window\.addEventListener\('unhandledrejection'/); +assert.match(source, /trackClientDiagnostic\('chunk_load_failed'/); +assert.match(mainSource, /startChunkLoadRecovery\(\)/); diff --git a/Elevate.Web/src/components/common/ErrorBoundary.jsx b/Elevate.Web/src/components/common/ErrorBoundary.jsx index 638a7815..9a6da460 100644 --- a/Elevate.Web/src/components/common/ErrorBoundary.jsx +++ b/Elevate.Web/src/components/common/ErrorBoundary.jsx @@ -11,6 +11,7 @@ * */ import { Component } from 'react'; +import { trackClientDiagnostic } from '../../services/clarity'; class ErrorBoundary extends Component { constructor(props) { @@ -24,6 +25,11 @@ class ErrorBoundary extends Component { componentDidCatch(error, info) { console.error('[ErrorBoundary]', error, info.componentStack); + trackClientDiagnostic('render_error', { + route: `${window.location.pathname}${window.location.search}`, + build_id: window.__BUILD_ID__ || 'unknown', + message: error?.message || 'unknown', + }); } render() { @@ -33,6 +39,7 @@ class ErrorBoundary extends Component { 💥

페이지를 표시할 수 없습니다

{this.state.errorMessage}

+

Build {window.__BUILD_ID__ || 'unknown'}