From 9cee6387c30c100501be5e51309effcc4bee506d Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 27 May 2026 17:39:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TradeSignalController.java | 33 ++++- .../repository/GcTradeSignalRepository.java | 12 ++ .../service/TradeSignalService.java | 46 +++++++ frontend/src/App.css | 52 ++++++++ .../components/TradeNotificationListPage.tsx | 63 +++++++++ .../src/contexts/TradeNotificationContext.tsx | 121 +++++++++++++++++- frontend/src/utils/backendApi.ts | 21 +++ 7 files changed, 344 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java index 6620636..0b8cad7 100644 --- a/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java +++ b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java @@ -12,8 +12,11 @@ import java.util.List; * 매매 시그널 이력 REST API. * *
- * GET  /api/trade-signals          → 전체 이력
- * GET  /api/trade-signals?market=  → 종목별 이력
+ * GET    /api/trade-signals              → 전체 이력
+ * GET    /api/trade-signals?market=      → 종목별 이력
+ * DELETE /api/trade-signals/{id}         → 단건 삭제
+ * DELETE /api/trade-signals              → 전체 삭제
+ * POST   /api/trade-signals/delete-batch → ID 목록 삭제
  * 
*/ @RestController @@ -36,6 +39,32 @@ public class TradeSignalController { return ResponseEntity.ok(result); } + @DeleteMapping("/{id}") + public ResponseEntity deleteOne( + @PathVariable Long id, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + service.deleteOne(id, parseUserId(userIdHeader), deviceId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping + public ResponseEntity> deleteAll( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + int deleted = service.deleteAll(parseUserId(userIdHeader), deviceId); + return ResponseEntity.ok(java.util.Map.of("deleted", deleted)); + } + + @PostMapping("/delete-batch") + public ResponseEntity> deleteBatch( + @RequestBody java.util.List ids, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + int deleted = service.deleteBatch(ids, parseUserId(userIdHeader), deviceId); + return ResponseEntity.ok(java.util.Map.of("deleted", deleted)); + } + private Long parseUserId(String h) { if (h == null || h.isBlank()) return null; try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; } diff --git a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java index 0b291ac..c264d32 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java @@ -2,6 +2,9 @@ package com.goldenchart.repository; import com.goldenchart.entity.GcTradeSignal; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -10,4 +13,13 @@ public interface GcTradeSignalRepository extends JpaRepository findByDeviceIdOrderByCreatedAtDesc(String deviceId); List findByUserIdOrderByCreatedAtDesc(Long userId); List findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market); + + @Modifying + @Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId") + void deleteByUserId(@Param("userId") Long userId); + + @Modifying + @Query("DELETE FROM GcTradeSignal e WHERE e.deviceId = :deviceId AND (e.userId IS NULL OR e.userId = 0)") + void deleteByDeviceId(@Param("deviceId") String deviceId); + } diff --git a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java index 86efedc..12e11a5 100644 --- a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java +++ b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java @@ -69,6 +69,52 @@ public class TradeSignalService { .stream().map(this::toDto).toList(); } + // ── 삭제 ───────────────────────────────────────────────────────────────── + + public void deleteOne(Long id, Long userId, String deviceId) { + if (id == null) return; + repo.findById(id).ifPresent(entity -> { + if (!owns(entity, userId, deviceId)) return; + repo.delete(entity); + log.info("[TradeSignal] deleted id={} market={}", id, entity.getMarket()); + }); + } + + public int deleteBatch(List ids, Long userId, String deviceId) { + if (ids == null || ids.isEmpty()) return 0; + List toDelete = ids.stream() + .distinct() + .map(repo::findById) + .filter(java.util.Optional::isPresent) + .map(java.util.Optional::get) + .filter(e -> owns(e, userId, deviceId)) + .toList(); + if (toDelete.isEmpty()) return 0; + repo.deleteAll(toDelete); + log.info("[TradeSignal] batch deleted count={}", toDelete.size()); + return toDelete.size(); + } + + public int deleteAll(Long userId, String deviceId) { + if (userId != null) { + long count = repo.findByUserIdOrderByCreatedAtDesc(userId).size(); + repo.deleteByUserId(userId); + log.info("[TradeSignal] deleted all for userId={} count≈{}", userId, count); + return (int) count; + } + String dev = deviceId != null ? deviceId : ""; + long count = repo.findByDeviceIdOrderByCreatedAtDesc(dev).size(); + repo.deleteByDeviceId(dev); + log.info("[TradeSignal] deleted all for deviceId={} count≈{}", dev, count); + return (int) count; + } + + private static boolean owns(GcTradeSignal entity, Long userId, String deviceId) { + if (userId != null && userId.equals(entity.getUserId())) return true; + if (deviceId == null || deviceId.isBlank()) return false; + return deviceId.equals(entity.getDeviceId()); + } + // ── 변환 ───────────────────────────────────────────────────────────────── private TradeSignalDto toDto(GcTradeSignal e) { diff --git a/frontend/src/App.css b/frontend/src/App.css index 76394c8..3561c16 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -11317,6 +11317,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .tnl-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; } .tnl-sub { font-size: 13px; color: var(--text3); margin: 0 0 14px; } .tnl-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.tnl-toolbar-divider { + width: 1px; + height: 22px; + background: var(--border); + flex-shrink: 0; +} +.tnl-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tnl-icon-btn:hover:not(:disabled) { + background: var(--bg4); + color: var(--text); + border-color: var(--accent, #7aa2f7); +} +.tnl-icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.tnl-icon-btn--danger:hover:not(:disabled) { + color: #f7768e; + border-color: rgba(247, 118, 142, 0.55); +} +.tnl-row-icon-btn { + flex-shrink: 0; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin-right: 2px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text3); + cursor: pointer; +} +.tnl-row-icon-btn:hover { + background: rgba(247, 118, 142, 0.12); + color: #f7768e; +} .tnl-chip { font-size: 12px; padding: 4px 10px; diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 308acc6..b83badf 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -17,6 +17,15 @@ import '../styles/virtualTradingDashboard.css'; type RightTab = 'trade' | 'orderbook'; +const IcTrash = () => ( + + + + + + +); + interface Props { theme: Theme; onGoToChart: (market: string) => void; @@ -52,6 +61,9 @@ export const TradeNotificationListPage: React.FC = ({ unreadCount, markAsRead, markAllAsRead, + deleteNotification, + deleteUnreadNotifications, + deleteAllNotifications, openDetail, refreshHistory, } = useTradeNotification(); @@ -127,6 +139,27 @@ export const TradeNotificationListPage: React.FC = ({ setRightTab('trade'); }, [markAsRead, handleSelectMarket]); + const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => { + e.stopPropagation(); + if (!window.confirm('이 알림을 삭제하시겠습니까?')) return; + await deleteNotification(item.id); + if (selectedNotifyId === item.id) setSelectedNotifyId(null); + }, [deleteNotification, selectedNotifyId]); + + const handleDeleteUnread = useCallback(async () => { + if (unreadCount === 0) return; + if (!window.confirm(`읽지 않은 알림 ${unreadCount}건을 삭제하시겠습니까?`)) return; + await deleteUnreadNotifications(); + setSelectedNotifyId(null); + }, [unreadCount, deleteUnreadNotifications]); + + const handleDeleteAll = useCallback(async () => { + if (allNotifications.length === 0) return; + if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return; + await deleteAllNotifications(); + setSelectedNotifyId(null); + }, [allNotifications.length, deleteAllNotifications]); + const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { setRightTab('trade'); const side = rowType === 'bid' ? 'buy' : 'sell'; @@ -174,6 +207,27 @@ export const TradeNotificationListPage: React.FC = ({ 모두 읽음 )} + {!item.isRead && } +