Files
travel/src/services/googleAuth.ts
Hyungi Ahn fd5a68e44a 🎯 프로젝트 리브랜딩: 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

🌟 새로운 기능:
- 사용자 인증 및 권한 관리
- 다중 여행 계획 관리
- 여행 템플릿 시스템
- 공유 링크 및 댓글 시스템
- 관리자 대시보드
2025-11-25 10:39:58 +09:00

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