🎯 프로젝트 리브랜딩: 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:
319
src/services/googleAuth.ts
Normal file
319
src/services/googleAuth.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user