알림삭제 기능 추가

This commit is contained in:
Macbook
2026-05-27 17:39:33 +09:00
parent 701a9b2881
commit 9cee6387c3
7 changed files with 344 additions and 4 deletions
@@ -14,6 +14,9 @@ import java.util.List;
* <pre> * <pre>
* GET /api/trade-signals → 전체 이력 * GET /api/trade-signals → 전체 이력
* GET /api/trade-signals?market= → 종목별 이력 * GET /api/trade-signals?market= → 종목별 이력
* DELETE /api/trade-signals/{id} → 단건 삭제
* DELETE /api/trade-signals → 전체 삭제
* POST /api/trade-signals/delete-batch → ID 목록 삭제
* </pre> * </pre>
*/ */
@RestController @RestController
@@ -36,6 +39,32 @@ public class TradeSignalController {
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@DeleteMapping("/{id}")
public ResponseEntity<Void> 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<java.util.Map<String, Integer>> 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<java.util.Map<String, Integer>> deleteBatch(
@RequestBody java.util.List<Long> 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) { private Long parseUserId(String h) {
if (h == null || h.isBlank()) return null; if (h == null || h.isBlank()) return null;
try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; } try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; }
@@ -2,6 +2,9 @@ package com.goldenchart.repository;
import com.goldenchart.entity.GcTradeSignal; import com.goldenchart.entity.GcTradeSignal;
import org.springframework.data.jpa.repository.JpaRepository; 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; import java.util.List;
@@ -10,4 +13,13 @@ public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Lo
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId); List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId); List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market); List<GcTradeSignal> 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);
} }
@@ -69,6 +69,52 @@ public class TradeSignalService {
.stream().map(this::toDto).toList(); .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<Long> ids, Long userId, String deviceId) {
if (ids == null || ids.isEmpty()) return 0;
List<GcTradeSignal> 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) { private TradeSignalDto toDto(GcTradeSignal e) {
+52
View File
@@ -11317,6 +11317,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.tnl-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; } .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-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 { 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 { .tnl-chip {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
@@ -17,6 +17,15 @@ import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook'; type RightTab = 'trade' | 'orderbook';
const IcTrash = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
interface Props { interface Props {
theme: Theme; theme: Theme;
onGoToChart: (market: string) => void; onGoToChart: (market: string) => void;
@@ -52,6 +61,9 @@ export const TradeNotificationListPage: React.FC<Props> = ({
unreadCount, unreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
deleteNotification,
deleteUnreadNotifications,
deleteAllNotifications,
openDetail, openDetail,
refreshHistory, refreshHistory,
} = useTradeNotification(); } = useTradeNotification();
@@ -127,6 +139,27 @@ export const TradeNotificationListPage: React.FC<Props> = ({
setRightTab('trade'); setRightTab('trade');
}, [markAsRead, handleSelectMarket]); }, [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') => { const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade'); setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell'; const side = rowType === 'bid' ? 'buy' : 'sell';
@@ -174,6 +207,27 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</button> </button>
)} )}
<span className="tnl-toolbar-divider" aria-hidden="true" />
<button
type="button"
className="tnl-icon-btn"
title="미확인 알림 삭제"
aria-label="미확인 알림 삭제"
disabled={unreadCount === 0}
onClick={() => void handleDeleteUnread()}
>
<IcTrash />
</button>
<button
type="button"
className="tnl-icon-btn tnl-icon-btn--danger"
title="모두 삭제"
aria-label="모두 삭제"
disabled={allNotifications.length === 0}
onClick={() => void handleDeleteAll()}
>
<IcTrash />
</button>
</div> </div>
</div> </div>
@@ -213,6 +267,15 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</span> </span>
{!item.isRead && <span className="tnl-dot" aria-hidden />} {!item.isRead && <span className="tnl-dot" aria-hidden />}
</button> </button>
<button
type="button"
className="tnl-row-icon-btn"
title="삭제"
aria-label="알림 삭제"
onClick={e => void handleDeleteOne(item, e)}
>
<IcTrash />
</button>
<button <button
type="button" type="button"
className="tnl-row-action" className="tnl-row-action"
@@ -14,7 +14,13 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import type { TradeSignalInfo } from '../components/TradeAlertModal'; import type { TradeSignalInfo } from '../components/TradeAlertModal';
import { loadTradeSignals, type TradeSignalDto } from '../utils/backendApi'; import {
deleteAllTradeSignals,
deleteTradeSignal,
deleteTradeSignalsBatch,
loadTradeSignals,
type TradeSignalDto,
} from '../utils/backendApi';
import { import {
normalizeTradeAlertSoundId, normalizeTradeAlertSoundId,
playTradeAlertSound, playTradeAlertSound,
@@ -22,6 +28,7 @@ import {
} from '../utils/tradeAlertSound'; } from '../utils/tradeAlertSound';
const STORAGE_KEY = 'gc_trade_notify_read_v1'; const STORAGE_KEY = 'gc_trade_notify_read_v1';
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
const MAX_HISTORY = 300; const MAX_HISTORY = 300;
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */ /** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
const MAX_TOAST_QUEUE = 50; const MAX_TOAST_QUEUE = 50;
@@ -48,6 +55,12 @@ interface TradeNotificationContextValue {
dismissAllToasts: () => void; dismissAllToasts: () => void;
markAsRead: (id: string) => void; markAsRead: (id: string) => void;
markAllAsRead: () => void; markAllAsRead: () => void;
/** 알림 목록에서 단건 삭제 */
deleteNotification: (id: string) => Promise<void>;
/** 읽지 않은(미확인) 알림만 삭제 */
deleteUnreadNotifications: () => Promise<void>;
/** 알림 목록 전체 삭제 */
deleteAllNotifications: () => Promise<void>;
refreshHistory: () => Promise<void>; refreshHistory: () => Promise<void>;
detailSignal: TradeSignalInfo | null; detailSignal: TradeSignalInfo | null;
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */ /** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
@@ -96,6 +109,22 @@ function saveReadIds(ids: Set<string>) {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function loadHiddenIds(): Set<string> {
try {
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function saveHiddenIds(ids: Set<string>) {
try {
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...ids]));
} catch { /* ignore */ }
}
export function useTradeNotification(): TradeNotificationContextValue { export function useTradeNotification(): TradeNotificationContextValue {
const ctx = useContext(TradeNotificationContext); const ctx = useContext(TradeNotificationContext);
if (!ctx) { if (!ctx) {
@@ -141,7 +170,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const dtos = await loadTradeSignals(); const dtos = await loadTradeSignals();
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */ /** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
const read = loadReadIds(); const read = loadReadIds();
const items = dtos.map(d => dtoToItem(d, read)); const hidden = loadHiddenIds();
const items = dtos
.map(d => dtoToItem(d, read))
.filter(item => !hidden.has(item.id));
let newlyArrived: TradeNotificationItem[] = []; let newlyArrived: TradeNotificationItem[] = [];
@@ -156,10 +188,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
map.set(n.id, wasRead ? { ...n, isRead: true } : n); map.set(n.id, wasRead ? { ...n, isRead: true } : n);
} }
for (const n of prev) { for (const n of prev) {
if (hidden.has(n.id)) continue;
if (!map.has(n.id)) map.set(n.id, n); if (!map.has(n.id)) map.set(n.id, n);
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true }); else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
} }
return [...map.values()] return [...map.values()]
.filter(n => !hidden.has(n.id))
.sort((a, b) => b.receivedAt - a.receivedAt) .sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY); .slice(0, MAX_HISTORY);
}); });
@@ -312,6 +346,86 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
setToastNotifications([]); setToastNotifications([]);
}, []); }, []);
const purgeNotifications = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
const hidden = loadHiddenIds();
ids.forEach(id => hidden.add(id));
saveHiddenIds(hidden);
setReadIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.delete(id));
saveReadIds(next);
readIdsRef.current = next;
return next;
});
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
setAllNotifications(prev => prev.filter(n => !idSet.has(n.id)));
if (detailSignal) {
const detailId = makeId({
market: detailSignal.market,
candleTime: detailSignal.candleTime,
signalType: detailSignal.signalType,
});
if (idSet.has(detailId)) {
setDetailSignal(null);
setDetailCentered(false);
}
}
}, [detailSignal]);
const findNotificationById = useCallback((id: string): TradeNotificationItem | undefined => {
return allNotifications.find(n => n.id === id)
?? toastNotificationsRef.current.find(n => n.id === id);
}, [allNotifications]);
const deleteNotification = useCallback(async (id: string) => {
const item = findNotificationById(id);
purgeNotifications([id]);
if (item?.dbId != null) {
try {
await deleteTradeSignal(item.dbId);
} catch (e) {
console.warn('[TradeNotification] 서버 삭제 실패:', e);
}
}
}, [findNotificationById, purgeNotifications]);
const deleteUnreadNotifications = useCallback(async () => {
const snapshot = [...allNotifications];
const unread = snapshot.filter(n => !n.isRead);
if (unread.length === 0) return;
const ids = unread.map(n => n.id);
const dbIds = unread.map(n => n.dbId).filter((x): x is number => x != null);
purgeNotifications(ids);
if (dbIds.length > 0) {
try {
await deleteTradeSignalsBatch(dbIds);
} catch (e) {
console.warn('[TradeNotification] 미확인 일괄 삭제 실패:', e);
}
}
}, [allNotifications, purgeNotifications]);
const deleteAllNotifications = useCallback(async () => {
const ids = [
...new Set([
...allNotifications.map(n => n.id),
...toastNotificationsRef.current.map(n => n.id),
]),
];
purgeNotifications(ids);
setToastNotifications([]);
saveReadIds(new Set());
saveHiddenIds(new Set());
try {
await deleteAllTradeSignals();
} catch (e) {
console.warn('[TradeNotification] 전체 삭제 실패:', e);
}
}, [allNotifications, purgeNotifications]);
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => { const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
setDetailCentered(options?.centered ?? false); setDetailCentered(options?.centered ?? false);
setDetailSignal({ setDetailSignal({
@@ -342,6 +456,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
dismissAllToasts, dismissAllToasts,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
deleteNotification,
deleteUnreadNotifications,
deleteAllNotifications,
refreshHistory, refreshHistory,
detailSignal, detailSignal,
detailCentered, detailCentered,
+21
View File
@@ -654,6 +654,27 @@ export async function loadTradeSignals(market?: string): Promise<TradeSignalDto[
return (await request<TradeSignalDto[]>(`/trade-signals${q}`)) ?? []; return (await request<TradeSignalDto[]>(`/trade-signals${q}`)) ?? [];
} }
/** 매매 시그널 단건 삭제 */
export async function deleteTradeSignal(id: number): Promise<void> {
await requestOrThrow<void>(`/trade-signals/${id}`, { method: 'DELETE' });
}
/** 매매 시그널 ID 목록 일괄 삭제 */
export async function deleteTradeSignalsBatch(ids: number[]): Promise<number> {
if (ids.length === 0) return 0;
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals/delete-batch', {
method: 'POST',
body: JSON.stringify(ids),
});
return res?.deleted ?? ids.length;
}
/** 매매 시그널 이력 전체 삭제 */
export async function deleteAllTradeSignals(): Promise<number> {
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals', { method: 'DELETE' });
return res?.deleted ?? 0;
}
/** /**
* 앱 시작 시 기본 설정 로드. * 앱 시작 시 기본 설정 로드.
* DB 에 없으면 백엔드 엔티티 기본값(KRW-BTC / 1D / dark 등)을 반환. * DB 에 없으면 백엔드 엔티티 기본값(KRW-BTC / 1D / dark 등)을 반환.