알림삭제 기능 추가
This commit is contained in:
@@ -14,6 +14,9 @@ import java.util.List;
|
||||
* <pre>
|
||||
* 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 목록 삭제
|
||||
* </pre>
|
||||
*/
|
||||
@RestController
|
||||
@@ -36,6 +39,32 @@ public class TradeSignalController {
|
||||
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) {
|
||||
if (h == null || h.isBlank()) 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 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<GcTradeSignal, Lo
|
||||
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
||||
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 삭제 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,15 @@ import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
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 {
|
||||
theme: Theme;
|
||||
onGoToChart: (market: string) => void;
|
||||
@@ -52,6 +61,9 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteUnreadNotifications,
|
||||
deleteAllNotifications,
|
||||
openDetail,
|
||||
refreshHistory,
|
||||
} = useTradeNotification();
|
||||
@@ -127,6 +139,27 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
모두 읽음
|
||||
</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>
|
||||
|
||||
@@ -213,6 +267,15 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
</span>
|
||||
{!item.isRead && <span className="tnl-dot" aria-hidden />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-icon-btn"
|
||||
title="삭제"
|
||||
aria-label="알림 삭제"
|
||||
onClick={e => void handleDeleteOne(item, e)}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-action"
|
||||
|
||||
@@ -14,7 +14,13 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { TradeSignalInfo } from '../components/TradeAlertModal';
|
||||
import { loadTradeSignals, type TradeSignalDto } from '../utils/backendApi';
|
||||
import {
|
||||
deleteAllTradeSignals,
|
||||
deleteTradeSignal,
|
||||
deleteTradeSignalsBatch,
|
||||
loadTradeSignals,
|
||||
type TradeSignalDto,
|
||||
} from '../utils/backendApi';
|
||||
import {
|
||||
normalizeTradeAlertSoundId,
|
||||
playTradeAlertSound,
|
||||
@@ -22,6 +28,7 @@ import {
|
||||
} from '../utils/tradeAlertSound';
|
||||
|
||||
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
||||
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
|
||||
const MAX_HISTORY = 300;
|
||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||
const MAX_TOAST_QUEUE = 50;
|
||||
@@ -48,6 +55,12 @@ interface TradeNotificationContextValue {
|
||||
dismissAllToasts: () => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
/** 알림 목록에서 단건 삭제 */
|
||||
deleteNotification: (id: string) => Promise<void>;
|
||||
/** 읽지 않은(미확인) 알림만 삭제 */
|
||||
deleteUnreadNotifications: () => Promise<void>;
|
||||
/** 알림 목록 전체 삭제 */
|
||||
deleteAllNotifications: () => Promise<void>;
|
||||
refreshHistory: () => Promise<void>;
|
||||
detailSignal: TradeSignalInfo | null;
|
||||
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
||||
@@ -96,6 +109,22 @@ function saveReadIds(ids: Set<string>) {
|
||||
} 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 {
|
||||
const ctx = useContext(TradeNotificationContext);
|
||||
if (!ctx) {
|
||||
@@ -141,7 +170,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
const dtos = await loadTradeSignals();
|
||||
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
|
||||
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[] = [];
|
||||
|
||||
@@ -156,10 +188,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
map.set(n.id, wasRead ? { ...n, isRead: true } : n);
|
||||
}
|
||||
for (const n of prev) {
|
||||
if (hidden.has(n.id)) continue;
|
||||
if (!map.has(n.id)) map.set(n.id, n);
|
||||
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
||||
}
|
||||
return [...map.values()]
|
||||
.filter(n => !hidden.has(n.id))
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||
.slice(0, MAX_HISTORY);
|
||||
});
|
||||
@@ -312,6 +346,86 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
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 }) => {
|
||||
setDetailCentered(options?.centered ?? false);
|
||||
setDetailSignal({
|
||||
@@ -342,6 +456,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
dismissAllToasts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteUnreadNotifications,
|
||||
deleteAllNotifications,
|
||||
refreshHistory,
|
||||
detailSignal,
|
||||
detailCentered,
|
||||
|
||||
@@ -654,6 +654,27 @@ export async function loadTradeSignals(market?: string): Promise<TradeSignalDto[
|
||||
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 등)을 반환.
|
||||
|
||||
Reference in New Issue
Block a user