🎯 프로젝트 리브랜딩: Kumamoto → Travel Planner v2.0

 주요 변경사항:
- 프로젝트 이름: kumamoto-travel-planner → travel-planner
- 버전 업그레이드: v1.0.0 → v2.0.0
- 멀티유저 시스템 구현 (JWT 인증)
- PostgreSQL 마이그레이션 시스템 추가
- Docker 컨테이너 이름 변경
- UI 브랜딩 업데이트 (Travel Planner)
- API 서버 및 인증 시스템 추가
- 여행 공유 기능 구현
- 템플릿 시스템 추가

🔧 기술 스택:
- Frontend: React + TypeScript + Vite
- Backend: Node.js + Express + JWT
- Database: PostgreSQL + 마이그레이션
- Infrastructure: Docker + Docker Compose

🌟 새로운 기능:
- 사용자 인증 및 권한 관리
- 다중 여행 계획 관리
- 여행 템플릿 시스템
- 공유 링크 및 댓글 시스템
- 관리자 대시보드
This commit is contained in:
Hyungi Ahn
2025-11-25 10:39:58 +09:00
parent a01897f50f
commit fd5a68e44a
81 changed files with 18420 additions and 399 deletions

179
server/routes/auth.js Normal file
View File

@@ -0,0 +1,179 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { query } = require('../db');
const router = express.Router();
// JWT 시크릿 (환경변수에서 가져오거나 기본값 사용)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
// 회원가입
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
if (existingUser.rows.length > 0) {
return res.status(400).json({ success: false, message: '이미 사용 중인 이메일입니다' });
}
// 비밀번호 해시
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// 사용자 생성
const result = await query(
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name, role, created_at',
[email, passwordHash, name]
);
const user = result.rows[0];
// JWT 토큰 생성
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
created_at: user.created_at
},
token,
message: '회원가입이 완료되었습니다'
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다' });
}
});
// 로그인
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// 사용자 조회
const result = await query(
'SELECT id, email, password_hash, name, role, is_active FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) {
return res.status(400).json({ success: false, message: '등록되지 않은 이메일입니다' });
}
const user = result.rows[0];
if (!user.is_active) {
return res.status(400).json({ success: false, message: '비활성화된 계정입니다' });
}
// 비밀번호 확인
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(400).json({ success: false, message: '비밀번호가 올바르지 않습니다' });
}
// 마지막 로그인 시간 업데이트
await query('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', [user.id]);
// JWT 토큰 생성
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
is_active: user.is_active
},
token,
message: '로그인되었습니다'
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: '로그인 중 오류가 발생했습니다' });
}
});
// 토큰 검증
router.get('/verify', authenticateToken, async (req, res) => {
try {
const result = await query(
'SELECT id, email, name, role, is_active, last_login FROM users WHERE id = $1',
[req.user.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다' });
}
const user = result.rows[0];
if (!user.is_active) {
return res.status(403).json({ success: false, message: '비활성화된 계정입니다' });
}
res.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
is_active: user.is_active,
last_login: user.last_login
}
});
} catch (error) {
console.error('Token verification error:', error);
res.status(500).json({ success: false, message: '토큰 검증 중 오류가 발생했습니다' });
}
});
// JWT 토큰 인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ success: false, message: '액세스 토큰이 필요합니다' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ success: false, message: '유효하지 않은 토큰입니다' });
}
req.user = user;
next();
});
}
// 관리자 권한 확인 미들웨어
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '관리자 권한이 필요합니다' });
}
next();
}
module.exports = {
router,
authenticateToken,
requireAdmin
};

View File

@@ -0,0 +1,77 @@
const express = require('express');
const { query } = require('../db');
const router = express.Router();
// 모든 기본 포인트 조회
router.get('/', async (req, res) => {
try {
const result = await query(
'SELECT * FROM base_points ORDER BY created_at DESC'
);
const basePoints = result.rows.map(row => ({
id: row.id.toString(),
name: row.name,
address: row.address,
type: row.type,
coordinates: {
lat: parseFloat(row.lat),
lng: parseFloat(row.lng)
},
memo: row.memo
}));
res.json(basePoints);
} catch (error) {
console.error('Error fetching base points:', error);
res.status(500).json({ error: error.message });
}
});
// 기본 포인트 추가
router.post('/', async (req, res) => {
try {
const { name, address, type, coordinates, memo } = req.body;
const result = await query(
`INSERT INTO base_points (name, address, type, lat, lng, memo)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, address || null, type, coordinates.lat, coordinates.lng, memo || null]
);
const basePoint = {
id: result.rows[0].id.toString(),
name: result.rows[0].name,
address: result.rows[0].address,
type: result.rows[0].type,
coordinates: {
lat: parseFloat(result.rows[0].lat),
lng: parseFloat(result.rows[0].lng)
},
memo: result.rows[0].memo
};
res.json(basePoint);
} catch (error) {
console.error('Error creating base point:', error);
res.status(500).json({ error: error.message });
}
});
// 기본 포인트 삭제
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
await query('DELETE FROM base_points WHERE id = $1', [id]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting base point:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

187
server/routes/setup.js Normal file
View File

@@ -0,0 +1,187 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { query } = require('../db');
const router = express.Router();
// 설정 상태 확인
router.get('/status', async (req, res) => {
try {
// 시스템 설정 확인
const settingsResult = await query('SELECT key, value FROM system_settings');
const settings = {};
settingsResult.rows.forEach(row => {
settings[row.key] = row.value;
});
// 관리자 계정 존재 여부 확인
const adminResult = await query('SELECT COUNT(*) as count FROM users WHERE role = $1', ['admin']);
const hasAdmin = parseInt(adminResult.rows[0].count) > 0;
// 전체 사용자 수
const userCountResult = await query('SELECT COUNT(*) as count FROM users');
const totalUsers = parseInt(userCountResult.rows[0].count);
const isSetupComplete = settings.setup_completed === 'true' && hasAdmin;
res.json({
isSetupComplete,
is_setup_required: !isSetupComplete,
setup_step: isSetupComplete ? 'completed' : 'initial',
has_admin: hasAdmin,
total_users: totalUsers,
version: settings.app_version || '2.0.0',
settings: {
jwt_secret_set: settings.jwt_secret_set === 'true',
google_maps_configured: settings.google_maps_configured === 'true',
email_configured: settings.email_configured === 'true'
}
});
} catch (error) {
console.error('Setup status check error:', error);
res.status(500).json({
success: false,
message: '설정 상태 확인 중 오류가 발생했습니다',
error: error.message
});
}
});
// 초기 관리자 계정 생성
router.post('/admin', async (req, res) => {
try {
const { name, email, password } = req.body;
// 입력 검증
if (!name || !email || !password) {
return res.status(400).json({
success: false,
message: '이름, 이메일, 비밀번호를 모두 입력해주세요'
});
}
// 이미 관리자가 있는지 확인
const existingAdmin = await query('SELECT id FROM users WHERE role = $1', ['admin']);
if (existingAdmin.rows.length > 0) {
return res.status(400).json({
success: false,
message: '이미 관리자 계정이 존재합니다'
});
}
// 이메일 중복 확인
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
if (existingUser.rows.length > 0) {
return res.status(400).json({
success: false,
message: '이미 사용 중인 이메일입니다'
});
}
// 비밀번호 해시
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// 관리자 계정 생성
const result = await query(
'INSERT INTO users (email, password_hash, name, role) VALUES ($1, $2, $3, $4) RETURNING id, email, name, role, created_at',
[email, passwordHash, name, 'admin']
);
const admin = result.rows[0];
// 설정 완료 표시
await query(
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
['true', 'setup_completed']
);
res.json({
success: true,
message: '관리자 계정이 생성되었습니다',
admin: {
id: admin.id,
email: admin.email,
name: admin.name,
role: admin.role,
created_at: admin.created_at
}
});
} catch (error) {
console.error('Admin creation error:', error);
res.status(500).json({
success: false,
message: '관리자 계정 생성 중 오류가 발생했습니다',
error: error.message
});
}
});
// 환경 설정 업데이트
router.post('/config', async (req, res) => {
try {
const { jwt_secret, google_maps_api_key, email_config } = req.body;
const updates = [];
// JWT 시크릿 설정
if (jwt_secret) {
process.env.JWT_SECRET = jwt_secret;
updates.push(['jwt_secret_set', 'true']);
}
// Google Maps API 키 설정
if (google_maps_api_key) {
process.env.GOOGLE_MAPS_API_KEY = google_maps_api_key;
updates.push(['google_maps_configured', 'true']);
}
// 이메일 설정
if (email_config) {
// 이메일 설정 로직 (SMTP 등)
updates.push(['email_configured', 'true']);
}
// 설정 업데이트
for (const [key, value] of updates) {
await query(
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
[value, key]
);
}
res.json({
success: true,
message: '환경 설정이 업데이트되었습니다',
updated: updates.map(([key]) => key)
});
} catch (error) {
console.error('Config update error:', error);
res.status(500).json({
success: false,
message: '환경 설정 업데이트 중 오류가 발생했습니다',
error: error.message
});
}
});
// 데이터베이스 연결 테스트
router.get('/test-db', async (req, res) => {
try {
const result = await query('SELECT NOW() as current_time, version() as db_version');
res.json({
success: true,
message: '데이터베이스 연결 성공',
data: result.rows[0]
});
} catch (error) {
console.error('Database test error:', error);
res.status(500).json({
success: false,
message: '데이터베이스 연결 실패',
error: error.message
});
}
});
module.exports = router;

View File

@@ -0,0 +1,183 @@
const express = require('express');
const { query } = require('../db');
const router = express.Router();
// 여행 계획 전체 조회 (최신 1개)
router.get('/', async (req, res) => {
try {
// 최신 여행 계획 조회
const planResult = await query(
'SELECT * FROM travel_plans ORDER BY created_at DESC LIMIT 1'
);
if (planResult.rows.length === 0) {
return res.json(null);
}
const plan = planResult.rows[0];
// 해당 계획의 모든 일정 조회
const schedules = await query(
`SELECT ds.id, ds.schedule_date,
json_agg(
json_build_object(
'id', a.id,
'time', a.time,
'title', a.title,
'description', a.description,
'location', a.location,
'type', a.type,
'coordinates', CASE
WHEN a.lat IS NOT NULL AND a.lng IS NOT NULL
THEN json_build_object('lat', a.lat, 'lng', a.lng)
ELSE NULL
END,
'images', a.images,
'links', a.links,
'relatedPlaces', (
SELECT json_agg(
json_build_object(
'id', rp.id,
'name', rp.name,
'description', rp.description,
'address', rp.address,
'coordinates', CASE
WHEN rp.lat IS NOT NULL AND rp.lng IS NOT NULL
THEN json_build_object('lat', rp.lat, 'lng', rp.lng)
ELSE NULL
END,
'memo', rp.memo,
'willVisit', rp.will_visit,
'category', rp.category,
'images', rp.images,
'links', rp.links
)
)
FROM related_places rp
WHERE rp.activity_id = a.id
)
) ORDER BY a.time
) FILTER (WHERE a.id IS NOT NULL) as activities
FROM day_schedules ds
LEFT JOIN activities a ON a.day_schedule_id = ds.id
WHERE ds.travel_plan_id = $1
GROUP BY ds.id, ds.schedule_date
ORDER BY ds.schedule_date`,
[plan.id]
);
const travelPlan = {
id: plan.id,
startDate: plan.start_date,
endDate: plan.end_date,
schedule: schedules.rows.map(row => ({
date: row.schedule_date,
activities: row.activities || []
})),
budget: {
total: 0,
accommodation: 0,
food: 0,
transportation: 0,
shopping: 0,
activities: 0
},
checklist: []
};
res.json(travelPlan);
} catch (error) {
console.error('Error fetching travel plan:', error);
res.status(500).json({ error: error.message });
}
});
// 여행 계획 저장/업데이트
router.post('/', async (req, res) => {
const client = await query('BEGIN');
try {
const { startDate, endDate, schedule } = req.body;
// 기존 계획 삭제 (단순화: 항상 최신 1개만 유지)
await query('DELETE FROM travel_plans');
// 새 여행 계획 생성
const planResult = await query(
'INSERT INTO travel_plans (start_date, end_date) VALUES ($1, $2) RETURNING id',
[startDate, endDate]
);
const planId = planResult.rows[0].id;
// 일정별 데이터 삽입
for (const day of schedule) {
// day_schedule 삽입
const scheduleResult = await query(
'INSERT INTO day_schedules (travel_plan_id, schedule_date) VALUES ($1, $2) RETURNING id',
[planId, day.date]
);
const scheduleId = scheduleResult.rows[0].id;
// activities 삽입
if (day.activities && day.activities.length > 0) {
for (const activity of day.activities) {
const activityResult = await query(
`INSERT INTO activities (
day_schedule_id, time, title, description, location, type, lat, lng, images, links
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[
scheduleId,
activity.time,
activity.title,
activity.description || null,
activity.location || null,
activity.type,
activity.coordinates?.lat || null,
activity.coordinates?.lng || null,
activity.images || null,
activity.links || null
]
);
const activityId = activityResult.rows[0].id;
// related_places 삽입
if (activity.relatedPlaces && activity.relatedPlaces.length > 0) {
for (const place of activity.relatedPlaces) {
await query(
`INSERT INTO related_places (
activity_id, name, description, address, lat, lng, memo, will_visit, category, images, links
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
activityId,
place.name,
place.description || null,
place.address || null,
place.coordinates?.lat || null,
place.coordinates?.lng || null,
place.memo || null,
place.willVisit || false,
place.category || 'other',
place.images || null,
place.links || null
]
);
}
}
}
}
}
await query('COMMIT');
res.json({ success: true, id: planId });
} catch (error) {
await query('ROLLBACK');
console.error('Error saving travel plan:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

88
server/routes/uploads.js Normal file
View File

@@ -0,0 +1,88 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const router = express.Router();
// uploads 디렉토리 생성
const uploadsDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Multer 설정: 파일 저장 위치와 파일명 설정
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
// 파일 필터: 이미지만 허용
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif, webp)'));
}
};
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB 제한
},
fileFilter: fileFilter
});
// 다중 이미지 업로드 (최대 5개)
router.post('/', upload.array('images', 5), (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
}
// 업로드된 파일의 URL 배열 생성
const fileUrls = req.files.map(file => `/uploads/${file.filename}`);
res.json({
success: true,
files: fileUrls,
message: `${req.files.length}개의 파일이 업로드되었습니다`
});
} catch (error) {
console.error('이미지 업로드 오류:', error);
res.status(500).json({ error: error.message });
}
});
// 단일 이미지 업로드
router.post('/single', upload.single('image'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
}
const fileUrl = `/uploads/${req.file.filename}`;
res.json({
success: true,
file: fileUrl,
message: '파일이 업로드되었습니다'
});
} catch (error) {
console.error('이미지 업로드 오류:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;