const { v4: uuidv4 } = require('uuid'); const mysql = require('mysql2/promise'); const CATEGORY_SET = new Set(['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER']); const seedRestaurants = [ { id: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', name: '강남스시마루', category: 'JAPANESE', address: '서울 강남구 테헤란로 123', phone: '02-1234-5678', lat: 37.5009, lng: 127.0368, }, { id: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222', name: '북경반점 선릉점', category: 'CHINESE', address: '서울 강남구 선릉로 88', phone: '02-7890-1234', lat: 37.5043, lng: 127.0496, }, { id: '3be86df0-8b69-4f1b-9f0f-c08726f78333', name: '파스타하우스 역삼', category: 'WESTERN', address: '서울 강남구 역삼로 201', phone: '02-2222-3333', lat: 37.4982, lng: 127.0397, }, { id: '4d645f7f-0f08-4cb2-b218-0dd07a838444', name: '한그릇집 역삼점', category: 'KOREAN', address: '서울 강남구 논현로 401', phone: '02-5566-7788', lat: 37.5012, lng: 127.0301, }, ]; const seedUsers = [ { id: '77a341ce-68b4-4fde-9f23-b64fa87cb111', providerUserId: 'demo-user-1', nickName: '나' }, { id: 'a8d1cd2d-f4b7-4f4f-b6b6-1be63e2f8222', providerUserId: 'user-a', nickName: '점심러버' }, { id: 'f1f4ea2a-07e9-4cb8-bd8d-a16792b96333', providerUserId: 'user-b', nickName: '직장인A' }, { id: '4f659ac3-35f1-4907-91c7-11fe2e87a444', providerUserId: 'user-c', nickName: '직장인C' }, { id: '555f6868-c2b5-4025-b0cb-f2f65f1bc555', providerUserId: 'user-d', nickName: '직장인D' }, ]; const seedReviews = [ { id: 'r-101', restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', providerUserId: 'user-a', userNickname: '점심러버', rating: 5, content: '초밥 신선하고 점심 세트 가성비 좋아요.', createdAt: '2026-04-10T03:10:00Z', }, { id: 'r-102', restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', providerUserId: 'demo-user-1', userNickname: '나', rating: 4, content: '웨이팅만 짧으면 자주 올 듯.', createdAt: '2026-04-12T02:20:00Z', }, { id: 'r-201', restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222', providerUserId: 'user-b', userNickname: '직장인A', rating: 4, content: '짬뽕 국물이 진해서 해장 점심으로 좋아요.', createdAt: '2026-04-08T04:10:00Z', }, ]; const seedLikes = [ { restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222', providerUserId: 'demo-user-1' }, { restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', providerUserId: 'user-a' }, { restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-c' }, { restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-d' }, ]; class MemoryStore { constructor() { this.restaurants = [...seedRestaurants]; this.users = seedUsers.map((u) => ({ ...u })); this.reviews = seedReviews.map((r) => ({ ...r })); this.likes = seedLikes.map((l) => ({ ...l })); } async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) { let items = this.restaurants .map((restaurant) => this.#toRestaurantListItem(restaurant, userExternalId, lat, lng)) .filter((item) => item.distanceMeters <= radiusMeters); if (category && CATEGORY_SET.has(category)) { items = items.filter((item) => item.category === category); } return sortItems(items, sort); } async getRestaurantById(restaurantId, userExternalId) { const restaurant = this.restaurants.find((item) => item.id === restaurantId); if (!restaurant) return null; return this.#toRestaurantListItem( restaurant, userExternalId, restaurant.lat, restaurant.lng, ); } async getReviews(restaurantId, userExternalId) { return this.reviews .filter((review) => review.restaurantId === restaurantId) .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)) .map((review) => ({ id: review.id, restaurantId: review.restaurantId, userExternalId: review.providerUserId, nickName: review.userNickname, rating: review.rating, content: review.content, createdAt: review.createdAt, updatedAt: review.createdAt, mine: review.providerUserId === userExternalId, })); } async upsertReview(restaurantId, userExternalId, rating, content) { const restaurant = this.restaurants.find((item) => item.id === restaurantId); if (!restaurant) return null; const user = this.#getOrCreateUser(userExternalId); const now = new Date().toISOString(); const existing = this.reviews.find( (review) => review.restaurantId === restaurantId && review.providerUserId === userExternalId, ); if (existing) { existing.rating = rating; existing.content = content; existing.createdAt = now; existing.userNickname = user.nickName; return { id: existing.id, restaurantId, userExternalId, nickName: user.nickName, rating, content, createdAt: now, updatedAt: now, mine: true, }; } const created = { id: uuidv4(), restaurantId, providerUserId: userExternalId, userNickname: user.nickName, rating, content, createdAt: now, }; this.reviews.push(created); return { id: created.id, restaurantId, userExternalId, nickName: user.nickName, rating, content, createdAt: now, updatedAt: now, mine: true, }; } async deleteReview(reviewId, userExternalId) { const index = this.reviews.findIndex((item) => item.id === reviewId); if (index < 0) return { deleted: false, reason: 'NOT_FOUND' }; if (this.reviews[index].providerUserId !== userExternalId) { return { deleted: false, reason: 'FORBIDDEN' }; } this.reviews.splice(index, 1); return { deleted: true }; } async toggleLike(restaurantId, userExternalId) { const restaurant = this.restaurants.find((item) => item.id === restaurantId); if (!restaurant) return null; const index = this.likes.findIndex( (item) => item.restaurantId === restaurantId && item.providerUserId === userExternalId, ); let liked = false; if (index >= 0) { this.likes.splice(index, 1); } else { this.likes.push({ restaurantId, providerUserId: userExternalId }); liked = true; } const likeCount = this.likes.filter((item) => item.restaurantId === restaurantId).length; return { liked, likeCount }; } async getMyReviews(userExternalId) { return this.reviews .filter((item) => item.providerUserId === userExternalId) .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)) .map((item) => ({ id: item.id, restaurantId: item.restaurantId, userExternalId, nickName: item.userNickname, rating: item.rating, content: item.content, createdAt: item.createdAt, updatedAt: item.createdAt, mine: true, })); } #getOrCreateUser(userExternalId) { const found = this.users.find((u) => u.providerUserId === userExternalId); if (found) return found; const created = { id: uuidv4(), providerUserId: userExternalId, nickName: userExternalId === 'demo-user-1' ? '나' : '사용자', }; this.users.push(created); return created; } #toRestaurantListItem(restaurant, userExternalId, lat, lng) { const restaurantReviews = this.reviews.filter((item) => item.restaurantId === restaurant.id); const reviewCount = restaurantReviews.length; const ratingSum = restaurantReviews.reduce((sum, item) => sum + item.rating, 0); const averageRating = reviewCount === 0 ? 0 : roundTo1(ratingSum / reviewCount); const likeCount = this.likes.filter((item) => item.restaurantId === restaurant.id).length; return { id: restaurant.id, name: restaurant.name, category: restaurant.category, distanceMeters: Math.round(haversineMeters(lat, lng, restaurant.lat, restaurant.lng)), averageRating, reviewCount, likeCount, likedByMe: this.likes.some( (item) => item.restaurantId === restaurant.id && item.providerUserId === userExternalId, ), address: restaurant.address, phone: restaurant.phone, lat: restaurant.lat, lng: restaurant.lng, }; } } class MySQLStore { constructor(pool) { this.pool = pool; } async bootstrap() { const [rows] = await this.pool.query('select count(*) as cnt from restaurant'); if ((rows[0]?.cnt ?? 0) > 0) { return; } for (const user of seedUsers) { await this.pool.query( `insert into app_user (id, provider, provider_user_id, nick_name) values (?, 'toss', ?, ?) on duplicate key update nick_name = values(nick_name)`, [user.id, user.providerUserId, user.nickName], ); } for (const restaurant of seedRestaurants) { await this.pool.query( `insert into restaurant (id, external_place_id, name, category, address, phone, lat, lng, is_active) values (?, ?, ?, ?, ?, ?, ?, ?, 1) on duplicate key update name = values(name), category = values(category), address = values(address), phone = values(phone), lat = values(lat), lng = values(lng), is_active = 1`, [ restaurant.id, `seed-${restaurant.id}`, restaurant.name, restaurant.category, restaurant.address, restaurant.phone, restaurant.lat, restaurant.lng, ], ); } for (const review of seedReviews) { const user = seedUsers.find((u) => u.providerUserId === review.providerUserId); if (!user) continue; await this.pool.query( `insert into restaurant_review (id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at) values (?, ?, ?, ?, ?, ?, ?, null) on duplicate key update rating = values(rating), content = values(content), updated_at = values(updated_at), deleted_at = null`, [ review.id, review.restaurantId, user.id, review.rating, review.content, review.createdAt, review.createdAt, ], ); await this.#refreshStat(review.restaurantId); } for (const like of seedLikes) { const user = seedUsers.find((u) => u.providerUserId === like.providerUserId); if (!user) continue; await this.pool.query( `insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`, [like.restaurantId, user.id], ); await this.#refreshStat(like.restaurantId); } } async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) { const user = await this.#getOrCreateUser(userExternalId); const params = [user.id]; let whereClause = 'where r.is_active = 1'; if (category && CATEGORY_SET.has(category)) { whereClause += ' and r.category = ?'; params.push(category); } const [rows] = await this.pool.query( `select r.id, r.name, r.category, r.address, r.phone, r.lat, r.lng, coalesce(s.review_count, 0) as reviewCount, coalesce(s.like_count, 0) as likeCount, coalesce(s.average_rating, 0.00) as averageRating, case when ul.user_id is null then 0 else 1 end as likedByMe from restaurant r left join restaurant_stat s on s.restaurant_id = r.id left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ? ${whereClause}`, params, ); let items = rows .map((row) => ({ id: row.id, name: row.name, category: row.category, address: row.address, phone: row.phone, lat: Number(row.lat), lng: Number(row.lng), distanceMeters: Math.round(haversineMeters(lat, lng, Number(row.lat), Number(row.lng))), averageRating: Number(row.averageRating), reviewCount: Number(row.reviewCount), likeCount: Number(row.likeCount), likedByMe: Number(row.likedByMe) === 1, })) .filter((item) => item.distanceMeters <= radiusMeters); return sortItems(items, sort); } async getRestaurantById(restaurantId, userExternalId) { const user = await this.#getOrCreateUser(userExternalId); const [rows] = await this.pool.query( `select r.id, r.name, r.category, r.address, r.phone, r.lat, r.lng, coalesce(s.review_count, 0) as reviewCount, coalesce(s.like_count, 0) as likeCount, coalesce(s.average_rating, 0.00) as averageRating, case when ul.user_id is null then 0 else 1 end as likedByMe from restaurant r left join restaurant_stat s on s.restaurant_id = r.id left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ? where r.id = ? and r.is_active = 1 limit 1`, [user.id, restaurantId], ); if (rows.length === 0) return null; const row = rows[0]; return { id: row.id, name: row.name, category: row.category, address: row.address, phone: row.phone, lat: Number(row.lat), lng: Number(row.lng), distanceMeters: 0, averageRating: Number(row.averageRating), reviewCount: Number(row.reviewCount), likeCount: Number(row.likeCount), likedByMe: Number(row.likedByMe) === 1, }; } async getReviews(restaurantId, userExternalId) { const user = await this.#getOrCreateUser(userExternalId); const [rows] = await this.pool.query( `select rr.id, rr.restaurant_id as restaurantId, rr.rating, rr.content, rr.created_at as createdAt, rr.updated_at as updatedAt, au.provider_user_id as userExternalId, au.nick_name as nickName, case when rr.user_id = ? then 1 else 0 end as mine from restaurant_review rr inner join app_user au on au.id = rr.user_id where rr.restaurant_id = ? and rr.deleted_at is null order by rr.created_at desc`, [user.id, restaurantId], ); return rows.map((row) => ({ id: row.id, restaurantId: row.restaurantId, userExternalId: row.userExternalId, nickName: row.nickName, rating: Number(row.rating), content: row.content, createdAt: toIso(row.createdAt), updatedAt: toIso(row.updatedAt), mine: Number(row.mine) === 1, })); } async upsertReview(restaurantId, userExternalId, rating, content) { const user = await this.#getOrCreateUser(userExternalId); const [restaurantRows] = await this.pool.query( `select id from restaurant where id = ? and is_active = 1 limit 1`, [restaurantId], ); if (restaurantRows.length === 0) return null; const reviewId = uuidv4(); await this.pool.query( `insert into restaurant_review (id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at) values (?, ?, ?, ?, ?, current_timestamp, current_timestamp, null) on duplicate key update rating = values(rating), content = values(content), deleted_at = null, updated_at = current_timestamp`, [reviewId, restaurantId, user.id, rating, content], ); await this.#refreshStat(restaurantId); const [rows] = await this.pool.query( `select rr.id, rr.rating, rr.content, rr.created_at as createdAt, rr.updated_at as updatedAt from restaurant_review rr where rr.restaurant_id = ? and rr.user_id = ? and rr.deleted_at is null limit 1`, [restaurantId, user.id], ); const row = rows[0]; return { id: row.id, restaurantId, userExternalId, nickName: user.nickName, rating: Number(row.rating), content: row.content, createdAt: toIso(row.createdAt), updatedAt: toIso(row.updatedAt), mine: true, }; } async deleteReview(reviewId, userExternalId) { const user = await this.#getOrCreateUser(userExternalId); const [targetRows] = await this.pool.query( `select restaurant_id as restaurantId, user_id as userId from restaurant_review where id = ? and deleted_at is null limit 1`, [reviewId], ); if (targetRows.length === 0) { return { deleted: false, reason: 'NOT_FOUND' }; } const target = targetRows[0]; if (target.userId !== user.id) { return { deleted: false, reason: 'FORBIDDEN' }; } await this.pool.query(`delete from restaurant_review where id = ?`, [reviewId]); await this.#refreshStat(target.restaurantId); return { deleted: true }; } async toggleLike(restaurantId, userExternalId) { const user = await this.#getOrCreateUser(userExternalId); const [restaurantRows] = await this.pool.query( `select id from restaurant where id = ? and is_active = 1 limit 1`, [restaurantId], ); if (restaurantRows.length === 0) return null; const [insertResult] = await this.pool.query( `insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`, [restaurantId, user.id], ); let liked = true; if ((insertResult.affectedRows ?? 0) === 0) { await this.pool.query( `delete from restaurant_like where restaurant_id = ? and user_id = ?`, [restaurantId, user.id], ); liked = false; } await this.#refreshStat(restaurantId); const [rows] = await this.pool.query( `select count(*) as cnt from restaurant_like where restaurant_id = ?`, [restaurantId], ); return { liked, likeCount: Number(rows[0]?.cnt ?? 0), }; } async getMyReviews(userExternalId) { const user = await this.#getOrCreateUser(userExternalId); const [rows] = await this.pool.query( `select rr.id, rr.restaurant_id as restaurantId, rr.rating, rr.content, rr.created_at as createdAt, rr.updated_at as updatedAt, au.nick_name as nickName from restaurant_review rr inner join app_user au on au.id = rr.user_id where rr.user_id = ? and rr.deleted_at is null order by rr.created_at desc`, [user.id], ); return rows.map((row) => ({ id: row.id, restaurantId: row.restaurantId, userExternalId, nickName: row.nickName, rating: Number(row.rating), content: row.content, createdAt: toIso(row.createdAt), updatedAt: toIso(row.updatedAt), mine: true, })); } async #getOrCreateUser(userExternalId) { const [rows] = await this.pool.query( `select id, nick_name as nickName from app_user where provider = 'toss' and provider_user_id = ? limit 1`, [userExternalId], ); if (rows.length > 0) { return { id: rows[0].id, nickName: rows[0].nickName }; } const createdId = uuidv4(); const nickName = userExternalId === 'demo-user-1' ? '나' : '사용자'; await this.pool.query( `insert into app_user (id, provider, provider_user_id, nick_name) values (?, 'toss', ?, ?)`, [createdId, userExternalId, nickName], ); return { id: createdId, nickName }; } async #refreshStat(restaurantId) { await this.pool.query(`call refresh_restaurant_stat(?)`, [restaurantId]); } } async function createStore() { const mysqlEnabled = Boolean(process.env.MYSQL_HOST || process.env.MYSQL_URL); if (!mysqlEnabled) { return { mode: 'memory', store: new MemoryStore(), }; } const pool = process.env.MYSQL_URL ? mysql.createPool(process.env.MYSQL_URL) : mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, waitForConnections: true, connectionLimit: 10, }); const store = new MySQLStore(pool); await store.bootstrap(); return { mode: 'mysql', store, }; } function sortItems(items, sort) { const copied = [...items]; if (sort === 'rating') { return copied.sort((a, b) => { if (b.averageRating === a.averageRating) { return b.reviewCount - a.reviewCount; } return b.averageRating - a.averageRating; }); } if (sort === 'likes') { return copied.sort((a, b) => { if (b.likeCount === a.likeCount) { return a.distanceMeters - b.distanceMeters; } return b.likeCount - a.likeCount; }); } return copied.sort((a, b) => a.distanceMeters - b.distanceMeters); } function roundTo1(value) { return Math.round(value * 10) / 10; } function haversineMeters(lat1, lng1, lat2, lng2) { const toRad = (value) => (value * Math.PI) / 180; const earthRadius = 6371000; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthRadius * c; } function toIso(dateLike) { return new Date(dateLike).toISOString(); } module.exports = { createStore, };