upload 기능 구현 전에 일단 커밋

This commit is contained in:
2026-08-20 16:00:15 +09:00
commit 2ed3c72b77
24 changed files with 4959 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
throw new Error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
}
const supabase = createClient(supabaseUrl, supabaseKey);
const nowIso = () => new Date().toISOString();
export async function isGusFresh(ttlMs) {
const { data, error } = await supabase
.from('gus')
.select('updated_at')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
}
export async function replaceGus(entries) {
const now = nowIso();
const rows = entries.map(({ code, name }) => ({ code, name, updated_at: now }));
const { error: delErr } = await supabase.from('gus').delete().neq('code', '');
if (delErr) throw delErr;
const { error } = await supabase.from('gus').insert(rows);
if (error) throw error;
}
export async function getGus() {
const { data, error } = await supabase.from('gus').select('code, name').order('code');
if (error) throw error;
return data || [];
}
export async function getGuByName(name) {
const { data, error } = await supabase
.from('gus')
.select('code, name')
.eq('name', name)
.maybeSingle();
if (error) throw error;
return data;
}
export async function isDongsFresh(guName, ttlMs) {
const { data, error } = await supabase
.from('dongs')
.select('updated_at')
.eq('gu_name', guName)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
}
export async function replaceDongs(guName, dongList) {
const now = nowIso();
const rows = dongList.map((dong) => ({ gu_name: guName, dong, updated_at: now }));
const { error: delErr } = await supabase.from('dongs').delete().eq('gu_name', guName);
if (delErr) throw delErr;
const { error } = await supabase.from('dongs').insert(rows);
if (error) throw error;
}
export async function getDongs(guName) {
const { data, error } = await supabase
.from('dongs')
.select('dong')
.eq('gu_name', guName)
.order('dong');
if (error) throw error;
return (data || []).map((r) => r.dong);
}
export async function insertPole(pole) {
const { error } = await supabase.from('poles').insert(pole);
if (error) throw error;
}
export async function getPole(id) {
const { data, error } = await supabase
.from('poles')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data;
}
export async function getAllPoles({ gu = '', dong = '', start = '', end = '' } = {}) {
let query = supabase.from('poles').select('*').order('timestamp', { ascending: false });
if (gu) query = query.eq('gu', gu);
if (dong) query = query.eq('dong', dong);
if (start) query = query.gte('timestamp', start);
if (end) query = query.lte('timestamp', end);
const { data, error } = await query;
if (error) throw error;
return (data || []).map((p) => ({
...p,
photoUrl: p.photo_path ? `/uploads/${p.photo_path}` : null,
}));
}
export async function deletePole(id) {
const { error } = await supabase.from('poles').delete().eq('id', id);
if (error) throw error;
}
export async function getAllDongs() {
const { data, error } = await supabase
.from('dongs')
.select('gu_name, dong')
.order('gu_name')
.order('dong');
if (error) throw error;
return data || [];
}
+133
View File
@@ -0,0 +1,133 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import * as db from './db.js';
import { fetchGus, fetchDongs } from './vworld.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const PORT = Number(process.env.PORT || 4000);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const app = express();
app.use(cors());
app.use(express.json());
app.use('/uploads', express.static(UPLOAD_DIR));
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const id = req.body?.id || `pole-${Date.now()}`;
const ext = path.extname(file.originalname) || '.jpg';
cb(null, `${id}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
});
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
app.get('/api/gus', async (_req, res) => {
try {
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
await db.replaceGus(await fetchGus());
}
res.json(await db.getGus());
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.get('/api/dongs', async (req, res) => {
const { gu } = req.query;
if (!gu) return res.status(400).json({ error: 'gu 파라미터가 필요합니다.' });
try {
if (!(await db.isDongsFresh(gu, CACHE_TTL_MS))) {
const guRow = await db.getGuByName(gu);
if (!guRow) return res.status(404).json({ error: `'${gu}' 구를 찾을 수 없습니다.` });
await db.replaceDongs(gu, await fetchDongs(guRow.code));
}
res.json(await db.getDongs(gu));
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.get('/api/poles', async (req, res) => {
const { gu = '', dong = '', start = '', end = '' } = req.query;
try {
res.json(await db.getAllPoles({ gu, dong, start, end }));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/poles', upload.single('photo'), async (req, res) => {
const { id, lat, lng, gu = '', dong = '', timestamp, level = '' } = req.body ?? {};
if (lat === undefined || lng === undefined || !timestamp) {
return res.status(400).json({ error: 'lat, lng, timestamp는 필수입니다.' });
}
const pole = {
id: id || `pole-${Date.now()}`,
lat: Number(lat),
lng: Number(lng),
gu,
dong,
timestamp,
level,
photo_path: req.file ? req.file.filename : null,
};
try {
await db.insertPole(pole);
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.status(201).json({
...pole,
photo_path: undefined,
photoUrl: pole.photo_path ? `/uploads/${pole.photo_path}` : null,
});
});
app.get('/api/all-dongs', async (_req, res) => {
try {
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
await db.replaceGus(await fetchGus());
}
const gus = await db.getGus();
for (const gu of gus) {
if (!(await db.isDongsFresh(gu.name, CACHE_TTL_MS))) {
await db.replaceDongs(gu.name, await fetchDongs(gu.code));
}
}
const allDongs = await db.getAllDongs();
res.json(allDongs);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/poles/:id', async (req, res) => {
const existing = await db.getPole(req.params.id);
if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' });
if (existing.photo_path) {
fs.rm(path.join(UPLOAD_DIR, path.basename(existing.photo_path)), () => {});
}
try {
await db.deletePole(existing.id);
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ removed: true });
});
app.listen(PORT, () => {
console.log(`Pole Mapper API server: http://localhost:${PORT}`);
});
+149
View File
@@ -0,0 +1,149 @@
import 'dotenv/config';
import { createClient } from '@supabase/supabase-js';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, basename } from 'path';
const SOURCE_URL = 'https://saftyovhcable.kr/addMarker.html';
const THUMB_BASE = 'https://saftyovhcable.kr/res/Thumb/';
const UPLOAD_DIR = join(import.meta.dirname, 'uploads');
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
async function fetchPageCSV() {
console.log('1) 페이지에서 CSV 데이터를 가져오는 중...');
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`페이지 요청 실패: ${res.status}`);
const html = await res.text();
const match = html.match(/<textarea[^>]*id="csvInput"[^>]*>([\s\S]*?)<\/textarea>/i);
if (!match) throw new Error('csvInput textarea를 찾을 수 없습니다.');
const csvText = match[1].trim();
const lines = csvText.split('\n').map((l) => l.trim()).filter(Boolean);
const header = lines[0];
const dataLines = lines.slice(1);
console.log(` - 헤더: ${header}`);
console.log(` - 데이터 행 수: ${dataLines.length}`);
return { header, dataLines };
}
function parseCSVLines(header, dataLines) {
console.log('2) CSV 파싱 중...');
const fields = header.split('|');
const rows = [];
for (const line of dataLines) {
const cols = line.split('|');
if (cols.length < fields.length) continue;
const obj = {};
for (let i = 0; i < fields.length; i++) {
obj[fields[i]] = (cols[i] || '').trim();
}
rows.push(obj);
}
const valid = rows.filter(
(r) => r.Path && r.Path.endsWith('.jpg') && parseFloat(r.GPS_lat) !== 0 && parseFloat(r.GPS_lon) !== 0
);
console.log(` - 전체 파싱: ${rows.length}행, 유효(GPS+사진): ${valid.length}행`);
return { rows, valid };
}
async function downloadThumbs(validRows) {
console.log('3) Thumb 이미지 다운로드 중...');
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
let downloaded = 0;
let skipped = 0;
let failed = 0;
const BATCH = 10;
for (let i = 0; i < validRows.length; i += BATCH) {
const batch = validRows.slice(i, i + BATCH);
const results = await Promise.allSettled(
batch.map(async (row) => {
const fileName = basename(row.Path);
const dest = join(UPLOAD_DIR, fileName);
if (existsSync(dest)) {
skipped++;
return;
}
const url = THUMB_BASE + encodeURIComponent(fileName);
const resp = await fetch(url);
if (!resp.ok) {
console.warn(` ⚠ 다운로드 실패: ${fileName} (${resp.status})`);
failed++;
return;
}
const buffer = Buffer.from(await resp.arrayBuffer());
writeFileSync(dest, buffer);
downloaded++;
})
);
results.forEach((r) => {
if (r.status === 'rejected') {
console.warn(` ⚠ 에러:`, r.reason?.message);
failed++;
}
});
if ((i + BATCH) % 100 === 0 || i + BATCH >= validRows.length) {
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length} (다운:${downloaded} 스킵:${skipped} 실패:${failed})`);
}
}
console.log(` 완료 - 다운:${downloaded} 스킵:${skipped} 실패:${failed}`);
}
async function insertToDB(validRows) {
console.log('4) Supabase poles 테이블에 데이터 삽입 중...');
const BATCH = 50;
let inserted = 0;
let failed = 0;
for (let i = 0; i < validRows.length; i += BATCH) {
const batch = validRows.slice(i, i + BATCH);
const poles = batch.map((row) => ({
id: row.Path.replace(/\.jpg$/i, ''),
lat: parseFloat(row.GPS_lat),
lng: parseFloat(row.GPS_lon),
gu: row.Area || '',
dong: row.Juso || '',
timestamp: row.Date || '',
level: row.Level || '',
photo_path: basename(row.Path),
}));
const { data, error } = await supabase.from('poles').upsert(poles, { onConflict: 'id' });
if (error) {
console.error(` ⚠ 배치 ${i}-${i + batch.length} 실패:`, error.message);
failed += batch.length;
} else {
inserted += (data?.length || batch.length);
}
if ((i + BATCH) % 200 === 0 || i + BATCH >= validRows.length) {
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length}`);
}
}
console.log(` 완료 - 삽입/업데이트: ${inserted}행, 실패: ${failed}행`);
}
// ── main ──
async function main() {
console.log('=== saftyovhcable.kr 데이터 마이그레이션 ===\n');
const { header, dataLines } = await fetchPageCSV();
const { valid } = parseCSVLines(header, dataLines);
await downloadThumbs(valid);
await insertToDB(valid);
console.log('\n=== 마이그레이션 완료 ===');
}
main().catch((e) => {
console.error('치명적 에러:', e);
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
function credentials() {
return {
key: process.env.VWORLD_API_KEY || process.env.VITE_VWORLD_API_KEY || '',
domain: process.env.VWORLD_DOMAIN || 'localhost',
};
}
async function fetchVworldFeatures(data, attrFilter) {
const { key, domain } = credentials();
if (!key) throw new Error('VWORLD_API_KEY 환경변수가 설정되지 않았습니다.');
const size = 1000;
const base = new URLSearchParams({
service: 'data',
request: 'GetFeature',
data,
format: 'json',
geometry: 'false',
size: String(size),
key,
});
if (attrFilter) base.set('attrFilter', attrFilter);
base.set('domain', domain);
const features = [];
let page = 1;
let total = Infinity;
while (features.length < total && page <= 20) {
const params = new URLSearchParams(base);
params.set('page', String(page));
const res = await fetch(`${VWORLD_ENDPOINT}?${params}`);
if (!res.ok) throw new Error(`VWorld 요청 실패 (HTTP ${res.status})`);
const json = await res.json();
const response = json.response;
if (response?.status !== 'OK') {
throw new Error(`VWorld 조회 실패: ${JSON.stringify(response ?? json)}`);
}
const result = response.result?.featureCollection?.features ?? [];
features.push(...result);
total = Number(response.record?.total ?? 0) || total;
if (result.length === 0) break;
page += 1;
}
return features;
}
export async function fetchGus() {
const features = await fetchVworldFeatures('LT_C_ADSIGG_INFO', 'full_nm:like:서울');
return features.map((f) => ({
code: f.properties.sig_cd,
name: f.properties.sig_kor_nm,
}));
}
export async function fetchDongs(guCode) {
const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`);
return features.map((f) => f.properties.emd_kor_nm).filter(Boolean);
}