✨ 주요 변경사항: - 프로젝트 이름: 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 🌟 새로운 기능: - 사용자 인증 및 권한 관리 - 다중 여행 계획 관리 - 여행 템플릿 시스템 - 공유 링크 및 댓글 시스템 - 관리자 대시보드
320 lines
9.4 KiB
TypeScript
320 lines
9.4 KiB
TypeScript
// Google OAuth 및 사용자 데이터 서비스
|
|
export interface GoogleUser {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
picture: string
|
|
locale: string
|
|
}
|
|
|
|
export interface SavedPlace {
|
|
place_id: string
|
|
name: string
|
|
formatted_address: string
|
|
geometry: {
|
|
location: {
|
|
lat: number
|
|
lng: number
|
|
}
|
|
}
|
|
rating?: number
|
|
user_ratings_total?: number
|
|
types: string[]
|
|
photos?: google.maps.places.PlacePhoto[]
|
|
saved_lists: string[] // 어떤 리스트에 저장되었는지
|
|
user_rating?: number // 사용자가 준 평점
|
|
user_review?: string // 사용자 리뷰
|
|
visited?: boolean // 방문 여부
|
|
}
|
|
|
|
export interface GoogleMyMap {
|
|
id: string
|
|
title: string
|
|
description?: string
|
|
places: SavedPlace[]
|
|
created_date: string
|
|
updated_date: string
|
|
}
|
|
|
|
class GoogleAuthService {
|
|
private gapi: any = null
|
|
private auth2: any = null
|
|
private isInitialized = false
|
|
|
|
// Google API 초기화
|
|
async initialize(clientId: string) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (typeof window === 'undefined') {
|
|
reject(new Error('Google Auth는 브라우저에서만 사용 가능합니다'))
|
|
return
|
|
}
|
|
|
|
// Google API 스크립트 로드
|
|
if (!window.gapi) {
|
|
const script = document.createElement('script')
|
|
script.src = 'https://apis.google.com/js/api.js'
|
|
script.onload = () => this.loadGapi(clientId, resolve, reject)
|
|
script.onerror = () => reject(new Error('Google API 스크립트 로드 실패'))
|
|
document.head.appendChild(script)
|
|
} else {
|
|
this.loadGapi(clientId, resolve, reject)
|
|
}
|
|
})
|
|
}
|
|
|
|
private loadGapi(clientId: string, resolve: () => void, reject: (error: Error) => void) {
|
|
window.gapi.load('auth2', () => {
|
|
window.gapi.auth2.init({
|
|
client_id: clientId,
|
|
scope: [
|
|
'profile',
|
|
'email',
|
|
'https://www.googleapis.com/auth/maps.readonly', // Google Maps 데이터 읽기
|
|
'https://www.googleapis.com/auth/mymaps.readonly' // My Maps 읽기
|
|
].join(' ')
|
|
}).then(() => {
|
|
this.gapi = window.gapi
|
|
this.auth2 = window.gapi.auth2.getAuthInstance()
|
|
this.isInitialized = true
|
|
resolve()
|
|
}).catch((error: any) => {
|
|
reject(new Error(`Google Auth 초기화 실패: ${error.error || error}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
// 로그인
|
|
async signIn(): Promise<GoogleUser> {
|
|
if (!this.isInitialized) {
|
|
throw new Error('Google Auth가 초기화되지 않았습니다')
|
|
}
|
|
|
|
try {
|
|
const authResult = await this.auth2.signIn()
|
|
const profile = authResult.getBasicProfile()
|
|
|
|
const user: GoogleUser = {
|
|
id: profile.getId(),
|
|
email: profile.getEmail(),
|
|
name: profile.getName(),
|
|
picture: profile.getImageUrl(),
|
|
locale: profile.getLocale() || 'ko'
|
|
}
|
|
|
|
// 로컬 스토리지에 사용자 정보 저장
|
|
localStorage.setItem('google_user', JSON.stringify(user))
|
|
|
|
return user
|
|
} catch (error) {
|
|
throw new Error(`로그인 실패: ${error}`)
|
|
}
|
|
}
|
|
|
|
// 로그아웃
|
|
async signOut(): Promise<void> {
|
|
if (!this.isInitialized) return
|
|
|
|
try {
|
|
await this.auth2.signOut()
|
|
localStorage.removeItem('google_user')
|
|
} catch (error) {
|
|
console.error('로그아웃 실패:', error)
|
|
}
|
|
}
|
|
|
|
// 현재 로그인된 사용자 정보
|
|
getCurrentUser(): GoogleUser | null {
|
|
const userStr = localStorage.getItem('google_user')
|
|
return userStr ? JSON.parse(userStr) : null
|
|
}
|
|
|
|
// 로그인 상태 확인
|
|
isSignedIn(): boolean {
|
|
return this.isInitialized && this.auth2?.isSignedIn.get() && this.getCurrentUser() !== null
|
|
}
|
|
|
|
// 저장된 장소 가져오기 (Google My Maps API 사용)
|
|
async getSavedPlaces(): Promise<SavedPlace[]> {
|
|
if (!this.isSignedIn()) {
|
|
throw new Error('로그인이 필요합니다')
|
|
}
|
|
|
|
try {
|
|
// Google My Maps API 호출
|
|
const response = await this.gapi.client.request({
|
|
path: 'https://www.googleapis.com/mymaps/v1/maps',
|
|
method: 'GET'
|
|
})
|
|
|
|
const savedPlaces: SavedPlace[] = []
|
|
|
|
// My Maps에서 장소 추출
|
|
if (response.result && response.result.maps) {
|
|
for (const map of response.result.maps) {
|
|
const mapPlaces = await this.getPlacesFromMap(map.id)
|
|
savedPlaces.push(...mapPlaces)
|
|
}
|
|
}
|
|
|
|
return savedPlaces
|
|
} catch (error) {
|
|
console.error('저장된 장소 가져오기 실패:', error)
|
|
// 폴백: 로컬 스토리지에서 이전에 저장된 데이터 사용
|
|
return this.getCachedSavedPlaces()
|
|
}
|
|
}
|
|
|
|
// 특정 My Map에서 장소들 가져오기
|
|
private async getPlacesFromMap(mapId: string): Promise<SavedPlace[]> {
|
|
try {
|
|
const response = await this.gapi.client.request({
|
|
path: `https://www.googleapis.com/mymaps/v1/maps/${mapId}/features`,
|
|
method: 'GET'
|
|
})
|
|
|
|
const places: SavedPlace[] = []
|
|
|
|
if (response.result && response.result.features) {
|
|
for (const feature of response.result.features) {
|
|
if (feature.geometry && feature.geometry.location) {
|
|
const place: SavedPlace = {
|
|
place_id: feature.properties?.place_id || `custom_${Date.now()}`,
|
|
name: feature.properties?.name || 'Unknown Place',
|
|
formatted_address: feature.properties?.address || '',
|
|
geometry: {
|
|
location: {
|
|
lat: feature.geometry.location.latitude,
|
|
lng: feature.geometry.location.longitude
|
|
}
|
|
},
|
|
types: feature.properties?.types || [],
|
|
saved_lists: [mapId],
|
|
user_rating: feature.properties?.rating,
|
|
user_review: feature.properties?.description
|
|
}
|
|
places.push(place)
|
|
}
|
|
}
|
|
}
|
|
|
|
return places
|
|
} catch (error) {
|
|
console.error(`Map ${mapId}에서 장소 가져오기 실패:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// My Maps 목록 가져오기
|
|
async getMyMaps(): Promise<GoogleMyMap[]> {
|
|
if (!this.isSignedIn()) {
|
|
throw new Error('로그인이 필요합니다')
|
|
}
|
|
|
|
try {
|
|
const response = await this.gapi.client.request({
|
|
path: 'https://www.googleapis.com/mymaps/v1/maps',
|
|
method: 'GET'
|
|
})
|
|
|
|
const myMaps: GoogleMyMap[] = []
|
|
|
|
if (response.result && response.result.maps) {
|
|
for (const map of response.result.maps) {
|
|
const places = await this.getPlacesFromMap(map.id)
|
|
|
|
const myMap: GoogleMyMap = {
|
|
id: map.id,
|
|
title: map.title || 'Untitled Map',
|
|
description: map.description,
|
|
places: places,
|
|
created_date: map.createTime,
|
|
updated_date: map.updateTime
|
|
}
|
|
|
|
myMaps.push(myMap)
|
|
}
|
|
}
|
|
|
|
// 로컬 캐시에 저장
|
|
localStorage.setItem('google_my_maps', JSON.stringify(myMaps))
|
|
|
|
return myMaps
|
|
} catch (error) {
|
|
console.error('My Maps 가져오기 실패:', error)
|
|
// 폴백: 캐시된 데이터 사용
|
|
return this.getCachedMyMaps()
|
|
}
|
|
}
|
|
|
|
// 캐시된 저장된 장소 가져오기
|
|
private getCachedSavedPlaces(): SavedPlace[] {
|
|
const cached = localStorage.getItem('google_saved_places')
|
|
return cached ? JSON.parse(cached) : []
|
|
}
|
|
|
|
// 캐시된 My Maps 가져오기
|
|
private getCachedMyMaps(): GoogleMyMap[] {
|
|
const cached = localStorage.getItem('google_my_maps')
|
|
return cached ? JSON.parse(cached) : []
|
|
}
|
|
|
|
// 저장된 장소를 일정에 추가하기 쉬운 형태로 변환
|
|
convertToItineraryFormat(savedPlace: SavedPlace) {
|
|
return {
|
|
id: `saved_${savedPlace.place_id}`,
|
|
title: savedPlace.name,
|
|
location: savedPlace.formatted_address,
|
|
description: savedPlace.user_review || `Google Maps에서 가져온 장소 (평점: ${savedPlace.user_rating || savedPlace.rating || 'N/A'})`,
|
|
type: this.inferActivityType(savedPlace.types),
|
|
coordinates: {
|
|
lat: savedPlace.geometry.location.lat,
|
|
lng: savedPlace.geometry.location.lng
|
|
},
|
|
time: '09:00', // 기본 시간
|
|
source: 'google_maps'
|
|
}
|
|
}
|
|
|
|
// 장소 타입에서 활동 타입 추론
|
|
private inferActivityType(types: string[]): 'attraction' | 'food' | 'accommodation' | 'transport' | 'other' {
|
|
if (types.includes('restaurant') || types.includes('food') || types.includes('meal_takeaway')) {
|
|
return 'food'
|
|
}
|
|
if (types.includes('lodging')) {
|
|
return 'accommodation'
|
|
}
|
|
if (types.includes('tourist_attraction') || types.includes('museum') || types.includes('park')) {
|
|
return 'attraction'
|
|
}
|
|
if (types.includes('transit_station') || types.includes('airport')) {
|
|
return 'transport'
|
|
}
|
|
return 'other'
|
|
}
|
|
}
|
|
|
|
// 싱글톤 인스턴스
|
|
export const googleAuthService = new GoogleAuthService()
|
|
|
|
// Google OAuth 설정
|
|
export const GoogleAuthConfig = {
|
|
// 실제 사용시에는 환경변수로 관리
|
|
CLIENT_ID: import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID || '',
|
|
|
|
// 필요한 권한 범위
|
|
SCOPES: [
|
|
'profile',
|
|
'email',
|
|
'https://www.googleapis.com/auth/maps.readonly',
|
|
'https://www.googleapis.com/auth/mymaps.readonly'
|
|
],
|
|
|
|
// 지원하는 기능들
|
|
FEATURES: {
|
|
MY_MAPS: true, // Google My Maps 지원
|
|
SAVED_PLACES: true, // 저장된 장소 지원
|
|
REVIEWS: false, // 리뷰 데이터 (제한적)
|
|
LOCATION_HISTORY: false // 위치 기록 (프라이버시 이슈)
|
|
}
|
|
} as const
|