From 31349948173e5a5fb429ece86d163fe81d939607 Mon Sep 17 00:00:00 2001 From: joungmin Date: Thu, 12 Mar 2026 14:10:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EA=B3=B5=EA=B0=9C=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 식당별 1:1 비공개 메모 CRUD (user_memos 테이블) - 내 기록에 리뷰/메모 탭 분리 - 백오피스 유저 관리에 메모 수/상세 표시 - 리뷰/메모 작성 시 현재 날짜 기본값 - 지도우선/목록우선 버튼 Material Symbols 아이콘 적용 Co-Authored-By: Claude Opus 4.6 --- .../controller/AdminUserController.java | 11 +- .../tasteby/controller/MemoController.java | 59 ++++++ .../main/java/com/tasteby/domain/Memo.java | 22 ++ .../java/com/tasteby/domain/UserInfo.java | 1 + .../java/com/tasteby/mapper/MemoMapper.java | 32 +++ .../java/com/tasteby/service/MemoService.java | 44 ++++ .../resources/mybatis/mapper/MemoMapper.xml | 59 ++++++ .../resources/mybatis/mapper/UserMapper.xml | 5 +- frontend/src/app/admin/page.tsx | 69 ++++++- frontend/src/app/page.tsx | 19 +- frontend/src/components/MemoSection.tsx | 194 ++++++++++++++++++ frontend/src/components/MyReviewsList.tsx | 146 +++++++++---- frontend/src/components/RestaurantDetail.tsx | 2 + frontend/src/components/ReviewSection.tsx | 1 + frontend/src/lib/api.ts | 48 +++++ 15 files changed, 667 insertions(+), 45 deletions(-) create mode 100644 backend-java/src/main/java/com/tasteby/controller/MemoController.java create mode 100644 backend-java/src/main/java/com/tasteby/domain/Memo.java create mode 100644 backend-java/src/main/java/com/tasteby/mapper/MemoMapper.java create mode 100644 backend-java/src/main/java/com/tasteby/service/MemoService.java create mode 100644 backend-java/src/main/resources/mybatis/mapper/MemoMapper.xml create mode 100644 frontend/src/components/MemoSection.tsx diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java index ea31a63..11cc239 100644 --- a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java +++ b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java @@ -1,7 +1,9 @@ package com.tasteby.controller; +import com.tasteby.domain.Memo; import com.tasteby.domain.Restaurant; import com.tasteby.domain.Review; +import com.tasteby.service.MemoService; import com.tasteby.service.ReviewService; import com.tasteby.service.UserService; import org.springframework.web.bind.annotation.*; @@ -15,10 +17,12 @@ public class AdminUserController { private final UserService userService; private final ReviewService reviewService; + private final MemoService memoService; - public AdminUserController(UserService userService, ReviewService reviewService) { + public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) { this.userService = userService; this.reviewService = reviewService; + this.memoService = memoService; } @GetMapping @@ -39,4 +43,9 @@ public class AdminUserController { public List userReviews(@PathVariable String userId) { return reviewService.findByUser(userId, 100, 0); } + + @GetMapping("/{userId}/memos") + public List userMemos(@PathVariable String userId) { + return memoService.findByUser(userId); + } } diff --git a/backend-java/src/main/java/com/tasteby/controller/MemoController.java b/backend-java/src/main/java/com/tasteby/controller/MemoController.java new file mode 100644 index 0000000..c761f35 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/MemoController.java @@ -0,0 +1,59 @@ +package com.tasteby.controller; + +import com.tasteby.domain.Memo; +import com.tasteby.security.AuthUtil; +import com.tasteby.service.MemoService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class MemoController { + + private final MemoService memoService; + + public MemoController(MemoService memoService) { + this.memoService = memoService; + } + + @GetMapping("/restaurants/{restaurantId}/memo") + public Memo getMemo(@PathVariable String restaurantId) { + String userId = AuthUtil.getUserId(); + Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId); + if (memo == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo"); + } + return memo; + } + + @PostMapping("/restaurants/{restaurantId}/memo") + public Memo upsertMemo(@PathVariable String restaurantId, + @RequestBody Map body) { + String userId = AuthUtil.getUserId(); + Double rating = body.get("rating") != null + ? ((Number) body.get("rating")).doubleValue() : null; + String text = (String) body.get("memo_text"); + LocalDate visitedAt = body.get("visited_at") != null + ? LocalDate.parse((String) body.get("visited_at")) : null; + return memoService.upsert(userId, restaurantId, rating, text, visitedAt); + } + + @GetMapping("/users/me/memos") + public List myMemos() { + return memoService.findByUser(AuthUtil.getUserId()); + } + + @DeleteMapping("/restaurants/{restaurantId}/memo") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteMemo(@PathVariable String restaurantId) { + String userId = AuthUtil.getUserId(); + if (!memoService.delete(userId, restaurantId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo"); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/domain/Memo.java b/backend-java/src/main/java/com/tasteby/domain/Memo.java new file mode 100644 index 0000000..db09e00 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/Memo.java @@ -0,0 +1,22 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Memo { + private String id; + private String userId; + private String restaurantId; + private Double rating; + private String memoText; + private String visitedAt; + private String createdAt; + private String updatedAt; + private String restaurantName; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/UserInfo.java b/backend-java/src/main/java/com/tasteby/domain/UserInfo.java index 828cca5..087fd67 100644 --- a/backend-java/src/main/java/com/tasteby/domain/UserInfo.java +++ b/backend-java/src/main/java/com/tasteby/domain/UserInfo.java @@ -22,4 +22,5 @@ public class UserInfo { private String createdAt; private int favoriteCount; private int reviewCount; + private int memoCount; } diff --git a/backend-java/src/main/java/com/tasteby/mapper/MemoMapper.java b/backend-java/src/main/java/com/tasteby/mapper/MemoMapper.java new file mode 100644 index 0000000..e0a639e --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/MemoMapper.java @@ -0,0 +1,32 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.Memo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MemoMapper { + + Memo findByUserAndRestaurant(@Param("userId") String userId, + @Param("restaurantId") String restaurantId); + + void insertMemo(@Param("id") String id, + @Param("userId") String userId, + @Param("restaurantId") String restaurantId, + @Param("rating") Double rating, + @Param("memoText") String memoText, + @Param("visitedAt") String visitedAt); + + int updateMemo(@Param("userId") String userId, + @Param("restaurantId") String restaurantId, + @Param("rating") Double rating, + @Param("memoText") String memoText, + @Param("visitedAt") String visitedAt); + + int deleteMemo(@Param("userId") String userId, + @Param("restaurantId") String restaurantId); + + List findByUser(@Param("userId") String userId); +} diff --git a/backend-java/src/main/java/com/tasteby/service/MemoService.java b/backend-java/src/main/java/com/tasteby/service/MemoService.java new file mode 100644 index 0000000..cd1dc79 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/MemoService.java @@ -0,0 +1,44 @@ +package com.tasteby.service; + +import com.tasteby.domain.Memo; +import com.tasteby.mapper.MemoMapper; +import com.tasteby.util.IdGenerator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +public class MemoService { + + private final MemoMapper mapper; + + public MemoService(MemoMapper mapper) { + this.mapper = mapper; + } + + public Memo findByUserAndRestaurant(String userId, String restaurantId) { + return mapper.findByUserAndRestaurant(userId, restaurantId); + } + + @Transactional + public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) { + String visitedStr = visitedAt != null ? visitedAt.toString() : null; + Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId); + if (existing != null) { + mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr); + } else { + mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr); + } + return mapper.findByUserAndRestaurant(userId, restaurantId); + } + + public boolean delete(String userId, String restaurantId) { + return mapper.deleteMemo(userId, restaurantId) > 0; + } + + public List findByUser(String userId) { + return mapper.findByUser(userId); + } +} diff --git a/backend-java/src/main/resources/mybatis/mapper/MemoMapper.xml b/backend-java/src/main/resources/mybatis/mapper/MemoMapper.xml new file mode 100644 index 0000000..ce8149b --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/MemoMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at) + VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText}, + + TO_DATE(#{visitedAt}, 'YYYY-MM-DD') + NULL + ) + + + + UPDATE user_memos SET + rating = #{rating}, + memo_text = #{memoText}, + visited_at = + TO_DATE(#{visitedAt}, 'YYYY-MM-DD') + NULL + , + updated_at = SYSTIMESTAMP + WHERE user_id = #{userId} AND restaurant_id = #{restaurantId} + + + + DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId} + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml b/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml index fa81446..f3764b9 100644 --- a/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml @@ -12,6 +12,7 @@ + SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at, NVL(fav.cnt, 0) AS favorite_count, - NVL(rev.cnt, 0) AS review_count + NVL(rev.cnt, 0) AS review_count, + NVL(memo.cnt, 0) AS memo_count FROM tasteby_users u LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id + LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id ORDER BY u.created_at DESC OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index b528550..95d60bf 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -2148,6 +2148,7 @@ interface AdminUser { created_at: string | null; favorite_count: number; review_count: number; + memo_count: number; } interface UserFavorite { @@ -2171,6 +2172,16 @@ interface UserReview { restaurant_name: string | null; } +interface UserMemo { + id: string; + restaurant_id: string; + rating: number | null; + memo_text: string | null; + visited_at: string | null; + created_at: string; + restaurant_name: string | null; +} + function UsersPanel() { const [users, setUsers] = useState([]); const [total, setTotal] = useState(0); @@ -2178,6 +2189,7 @@ function UsersPanel() { const [selectedUser, setSelectedUser] = useState(null); const [favorites, setFavorites] = useState([]); const [reviews, setReviews] = useState([]); + const [memos, setMemos] = useState([]); const [detailLoading, setDetailLoading] = useState(false); const perPage = 20; @@ -2200,17 +2212,20 @@ function UsersPanel() { setSelectedUser(null); setFavorites([]); setReviews([]); + setMemos([]); return; } setSelectedUser(u); setDetailLoading(true); try { - const [favs, revs] = await Promise.all([ + const [favs, revs, mems] = await Promise.all([ api.getAdminUserFavorites(u.id), api.getAdminUserReviews(u.id), + api.getAdminUserMemos(u.id), ]); setFavorites(favs); setReviews(revs); + setMemos(mems); } catch (e) { console.error(e); } finally { @@ -2233,6 +2248,7 @@ function UsersPanel() { 이메일 찜 리뷰 + 메모 가입일 @@ -2284,6 +2300,15 @@ function UsersPanel() { 0 )} + + {u.memo_count > 0 ? ( + + {u.memo_count} + + ) : ( + 0 + )} + {u.created_at?.slice(0, 10) || "-"} @@ -2343,7 +2368,7 @@ function UsersPanel() { {detailLoading ? (

로딩 중...

) : ( -
+
{/* Favorites */}

@@ -2419,6 +2444,46 @@ function UsersPanel() {

)}
+ + {/* Memos */} +
+

+ 작성한 메모 ({memos.length}) +

+ {memos.length === 0 ? ( +

작성한 메모가 없습니다.

+ ) : ( +
+ {memos.map((m) => ( +
+
+ + {m.restaurant_name || "알 수 없음"} + + {m.rating && ( + + {"★".repeat(Math.round(m.rating))} {m.rating} + + )} +
+ {m.memo_text && ( +

+ {m.memo_text} +

+ )} +
+ {m.visited_at && `방문: ${m.visited_at} · `} + {m.created_at?.slice(0, 10)} + 비공개 +
+
+ ))} +
+ )} +
)} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a6224e1..50388b5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoogleLogin } from "@react-oauth/google"; import LoginMenu from "@/components/LoginMenu"; import { api } from "@/lib/api"; -import type { Restaurant, Channel, Review } from "@/lib/api"; +import type { Restaurant, Channel, Review, Memo } from "@/lib/api"; import { useAuth } from "@/lib/auth-context"; import MapView, { MapBounds, FlyTo } from "@/components/MapView"; import SearchBar from "@/components/SearchBar"; @@ -191,6 +191,7 @@ export default function Home() { const [showFavorites, setShowFavorites] = useState(false); const [showMyReviews, setShowMyReviews] = useState(false); const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]); + const [myMemos, setMyMemos] = useState<(Memo & { restaurant_name: string | null })[]>([]); const [visits, setVisits] = useState<{ today: number; total: number } | null>(null); const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 }); const [isSearchResult, setIsSearchResult] = useState(false); @@ -518,10 +519,15 @@ export default function Home() { if (showMyReviews) { setShowMyReviews(false); setMyReviews([]); + setMyMemos([]); } else { try { - const reviews = await api.getMyReviews(); + const [reviews, memos] = await Promise.all([ + api.getMyReviews(), + api.getMyMemos(), + ]); setMyReviews(reviews); + setMyMemos(memos); setShowMyReviews(true); setShowFavorites(false); setSelected(null); @@ -534,7 +540,8 @@ export default function Home() { const sidebarContent = showMyReviews ? ( { setShowMyReviews(false); setMyReviews([]); }} + memos={myMemos} + onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }} onSelectRestaurant={async (restaurantId) => { try { const r = await api.getRestaurant(restaurantId); @@ -563,7 +570,8 @@ export default function Home() { const mobileListContent = showMyReviews ? ( { setShowMyReviews(false); setMyReviews([]); }} + memos={myMemos} + onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }} onSelectRestaurant={async (restaurantId) => { try { const r = await api.getRestaurant(restaurantId); @@ -618,7 +626,7 @@ export default function Home() { : "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500" }`} > - {viewMode === "map" ? "🗺 지도우선" : "☰ 목록우선"} + {viewMode === "map" ? "지도우선" : "목록우선"} {user && ( <> @@ -1193,6 +1201,7 @@ export default function Home() { ) : ( {}} onSelectRestaurant={async (restaurantId) => { try { diff --git a/frontend/src/components/MemoSection.tsx b/frontend/src/components/MemoSection.tsx new file mode 100644 index 0000000..1aa1cb8 --- /dev/null +++ b/frontend/src/components/MemoSection.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Memo } from "@/lib/api"; +import { useAuth } from "@/lib/auth-context"; +import Icon from "@/components/Icon"; + +interface MemoSectionProps { + restaurantId: string; +} + +function StarSelector({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { + return ( +
+ 별점: + {[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => ( + + ))} +
+ ); +} + +function StarDisplay({ rating }: { rating: number }) { + const stars = []; + for (let i = 1; i <= 5; i++) { + stars.push( + = i - 0.5 ? "text-yellow-500" : "text-gray-300"}> + ★ + + ); + } + return {stars}; +} + +export default function MemoSection({ restaurantId }: MemoSectionProps) { + const { user } = useAuth(); + const [memo, setMemo] = useState(null); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(false); + + // Form state + const [rating, setRating] = useState(3); + const [text, setText] = useState(""); + const [visitedAt, setVisitedAt] = useState(new Date().toISOString().slice(0, 10)); + const [submitting, setSubmitting] = useState(false); + + const loadMemo = useCallback(() => { + if (!user) { setLoading(false); return; } + setLoading(true); + api.getMemo(restaurantId) + .then(setMemo) + .catch(() => setMemo(null)) + .finally(() => setLoading(false)); + }, [restaurantId, user]); + + useEffect(() => { + loadMemo(); + }, [loadMemo]); + + if (!user) return null; + + const startEdit = () => { + if (memo) { + setRating(memo.rating || 3); + setText(memo.memo_text || ""); + setVisitedAt(memo.visited_at || new Date().toISOString().slice(0, 10)); + } else { + setRating(3); + setText(""); + setVisitedAt(new Date().toISOString().slice(0, 10)); + } + setEditing(true); + setShowForm(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + const saved = await api.upsertMemo(restaurantId, { + rating, + memo_text: text || undefined, + visited_at: visitedAt || undefined, + }); + setMemo(saved); + setShowForm(false); + setEditing(false); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!confirm("메모를 삭제하시겠습니까?")) return; + await api.deleteMemo(restaurantId); + setMemo(null); + }; + + return ( +
+
+ +

내 메모

+ 비공개 +
+ + {loading ? ( +
+
+
+
+ ) : showForm ? ( +
+ +