diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java index 98bb1b9..071d387 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardMapper.java @@ -57,4 +57,32 @@ public interface AdminDashboardMapper { LIMIT 20 """) List> findLiveEvents(@Param("tenantId") String tenantId); + + /** + * 관리자 집계 지표 원자 집계 (SCR-16 실전환) — 실 테이블 존재분만. + * event·app_user·visitor_registration 은 테넌트 스코프, tenant·company 는 공용 레지스트리(§멀티테넌시 1단계 유지). + * 오늘 체크인 = checkin_state='done' 이고 checkin_at 이 오늘(테넌트 행사 조인). 신규 스키마 없음. + */ + @Select(""" + SELECT + (SELECT count(*) FROM app_user + WHERE tenant_id = #{tenantId}) AS "totalUsers", + (SELECT count(*) FROM app_user + WHERE tenant_id = #{tenantId} AND status = 'ACTIVE') AS "activeUsers", + (SELECT count(*) FROM event + WHERE tenant_id = #{tenantId}) AS "totalEvents", + (SELECT count(*) FROM event + WHERE tenant_id = #{tenantId} + AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS "ongoingEvents", + (SELECT count(*) FROM event + WHERE tenant_id = #{tenantId} AND start_date > CURRENT_DATE) AS "upcomingEvents", + (SELECT count(*) FROM tenant) AS "tenantCount", + (SELECT count(*) FROM company) AS "companyCount", + (SELECT count(*) FROM visitor_registration vr + JOIN event e ON e.id = vr.event_id + WHERE e.tenant_id = #{tenantId} + AND vr.checkin_state = 'done' + AND vr.checkin_at::date = CURRENT_DATE) AS "todayCheckins" + """) + Map findStatCounts(@Param("tenantId") String tenantId); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java index c2dc4c2..ddf9faa 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminDashboardService.java @@ -1,6 +1,7 @@ package com.zioinfo.kintex.admin; import com.zioinfo.kintex.admin.dto.AdminDashboardDto; +import com.zioinfo.kintex.admin.dto.AdminStatsDto; import com.zioinfo.kintex.tenant.TenantContext; import org.springframework.stereotype.Service; @@ -57,6 +58,24 @@ public class AdminDashboardService { return new AdminDashboardDto(kpis, visitorTrend, live, tenants); } + /** + * 관리자 집계 지표(SCR-16 실전환) — 실 테이블 존재분만 반환. 테넌트 스코프는 대시보드와 동일(§8-2). + * 실시간 방문객·주차·라이브 IoT 지표는 미포함(M10/M14 이후) — 화면에서 정직한 게이트 유지. + */ + public AdminStatsDto getStats(String tenant) { + String tenantId = TenantContext.currentTenantId(); + Map c = mapper.findStatCounts(tenantId); + return new AdminStatsDto( + lng(c == null ? null : c.get("totalUsers")), + lng(c == null ? null : c.get("activeUsers")), + lng(c == null ? null : c.get("totalEvents")), + lng(c == null ? null : c.get("ongoingEvents")), + lng(c == null ? null : c.get("upcomingEvents")), + lng(c == null ? null : c.get("tenantCount")), + lng(c == null ? null : c.get("companyCount")), + lng(c == null ? null : c.get("todayCheckins"))); + } + private static long lng(Object o) { if (o == null) return 0L; if (o instanceof Number n) return n.longValue(); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminStatsController.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminStatsController.java new file mode 100644 index 0000000..16fddff --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminStatsController.java @@ -0,0 +1,37 @@ +package com.zioinfo.kintex.admin; + +import com.zioinfo.kintex.admin.dto.AdminStatsDto; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 관리자 집계 지표 API (SCR-16 실전환 · UNDEVELOPED_BACKLOG §1). + * 시스템관리자(홀매니저/ADMIN)만 접근 — 대시보드 컨트롤러와 동일 가드(SystemAccessGuard.requireAdmin). + * 실 테이블 존재분(사용자·행사·테넌트·등록업체·오늘 체크인)만 집계. 실시간 방문객·주차·라이브는 미포함. + */ +@RestController +@RequestMapping("/api/admin/stats") +public class AdminStatsController { + + private final AdminDashboardService service; + private final SystemAccessGuard guard; + + public AdminStatsController(AdminDashboardService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — 실 집계 가능한 관리자 지표(개수만, 자격증명·PII 미포함). */ + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false, defaultValue = "KINTEX") String tenant) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.getStats(tenant)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminStatsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminStatsDto.java new file mode 100644 index 0000000..39e560a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/AdminStatsDto.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.admin.dto; + +/** + * 관리자 집계 지표 (SCR-16 실전환 · UNDEVELOPED_BACKLOG §1). + * 이미 테이블이 존재하는 실 집계 가능 지표만 노출한다 — event · app_user · tenant · company · visitor_registration. + * 실시간 방문객·주차·라이브 IoT 지표는 M10/M14 센서·집계 인프라 이후 → 본 계약에 미포함(가짜 수치 생성 금지). + * 자격증명·PII(이메일 원문 등)는 응답에 포함하지 않는다(개수 집계만). + */ +public record AdminStatsDto( + long totalUsers, + long activeUsers, + long totalEvents, + long ongoingEvents, + long upcomingEvents, + long tenantCount, + long companyCount, + long todayCheckins +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionRealtimeService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionRealtimeService.java new file mode 100644 index 0000000..36c8c08 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionRealtimeService.java @@ -0,0 +1,117 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.RankingSnapshot; +import com.zioinfo.kintex.auction.dto.AuctionDtos.RankRow; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * M15 옥션 실시간 순위 브로드캐스트(폴링 → WebSocket 푸시 전환). + * + *

구독 토픽: {@code /topic/auctions/{auctionId}/ranking} (기존 {@code /topic/render/{jobId}} 컨벤션과 정합). + * 렌더 완료 푸시와 동일한 STOMP 인프라({@link com.zioinfo.kintex.config.WebSocketConfig})를 재사용한다 — + * 브로커 설정은 신설하지 않는다. + * + *

봉인 입찰 보안: 페이로드({@link RankingSnapshot})는 모든 구독자에게 동일 전송되므로 + * {@link AuctionService#publicRanking}(뷰어별 개인화 없음·최저가만 공개)만 담는다. 개인화 순위(내 순위·금액)는 + * 클라이언트가 인증된 상세 API로 재조회하여 서버 마스킹을 그대로 적용받는다. + * + *

트랜잭션 경계: 순위 변동 트랜잭션이 커밋된 뒤에만 전송한다(afterCommit). 롤백 시 미전송 — + * 유령 갱신·구독자 재조회 레이스(커밋 전 조회)를 방지한다. + */ +@Service +public class AuctionRealtimeService { + + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuctionRealtimeService.class); + + private final SimpMessagingTemplate messaging; + private final AuctionMapper mapper; + + public AuctionRealtimeService(SimpMessagingTemplate messaging, AuctionMapper mapper) { + this.messaging = messaging; + this.mapper = mapper; + } + + /** + * 순위 변동 이벤트를 커밋 후 브로드캐스트한다. + * + * @param auctionId 옥션 ID + * @param event BID | CLOSE | AWARD (원인 라벨) + */ + public void broadcastRanking(String auctionId, String event) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + sendSafe(auctionId, event); + } + }); + } else { + sendSafe(auctionId, event); + } + } + + /** 전송 실패는 격리(순위 갱신은 폴백 폴링이 보장) — 스택트레이스 미노출. */ + private void sendSafe(String auctionId, String event) { + try { + RankingSnapshot snap = buildSnapshot(auctionId, event); + if (snap == null) { + return; + } + messaging.convertAndSend("/topic/auctions/" + auctionId + "/ranking", snap); + } catch (RuntimeException e) { + log.warn("옥션 순위 브로드캐스트 실패(무시·폴링 폴백): auction={} reason={}", + auctionId, e.getClass().getSimpleName()); + } + } + + private RankingSnapshot buildSnapshot(String auctionId, String event) { + Map a = mapper.findAuction(auctionId); + if (a == null) { + return null; + } + boolean closed = bool(a.get("closed")); + List ranking = AuctionService.publicRanking(mapper.listRanking(auctionId)); + return new RankingSnapshot( + auctionId, str(a.get("eventId")), event, + intOr(a.get("round"), 1), !closed, + intOr(a.get("bidderCount"), 0), lng(a.get("lowestPrice")), + ranking, Instant.now().toString()); + } + + // ── 로컬 매핑 헬퍼(AuctionService 와 동일 규칙, 무상태) ── + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static boolean bool(Object o) { + if (o instanceof Boolean b) return b; + return o != null && Boolean.parseBoolean(String.valueOf(o)); + } + + private static int intOr(Object o, int def) { + if (o == null) return def; + if (o instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return def; + } + } + + private static Long lng(Object o) { + if (o == null) return null; + if (o instanceof Number n) return n.longValue(); + try { + return Long.valueOf(String.valueOf(o)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java index e35c424..62b903a 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java @@ -37,17 +37,20 @@ public class AuctionService { private final com.zioinfo.kintex.work.notification.NotificationService notificationService; private final com.zioinfo.kintex.mail.MailService mailService; private final com.zioinfo.kintex.mail.MailProperties mailProperties; + private final AuctionRealtimeService realtime; public AuctionService(AuctionMapper mapper, com.zioinfo.kintex.settlement.SettlementService settlementService, com.zioinfo.kintex.work.notification.NotificationService notificationService, com.zioinfo.kintex.mail.MailService mailService, - com.zioinfo.kintex.mail.MailProperties mailProperties) { + com.zioinfo.kintex.mail.MailProperties mailProperties, + AuctionRealtimeService realtime) { this.mapper = mapper; this.settlementService = settlementService; this.notificationService = notificationService; this.mailService = mailService; this.mailProperties = mailProperties; + this.realtime = realtime; } // ── 목록(SCR-26) ────────────────────────────────────────────────────────── @@ -128,6 +131,25 @@ public class AuctionService { return out; } + /** + * 공개 순위(브로드캐스트용). {@link #buildRanking}를 myCompanyId=null·sealed 로 호출한 것과 동일 — + * 즉 비응찰 뷰어가 폴링으로 보는 순위(최저가 금액만 공개, 그 외 타사 금액·업체명 마스킹). + * 뷰어별 개인화(isMe·내 금액)는 절대 포함하지 않으므로 모든 구독자에게 전송해도 봉인 규칙을 위반하지 않는다. + */ + static List publicRanking(List> rows) { + List out = new ArrayList<>(); + int rank = 0; + for (Map row : rows) { + rank++; + boolean isLowest = rank == 1; + boolean reveal = isLowest; // isMe 는 항상 false — 내 금액 노출 없음 + String alias = isLowest ? "현재 최저가" : "업체 " + rank; + out.add(new RankRow(rank, alias, false, isLowest, !reveal, + reveal ? lngPrim(row.get("total")) : null)); + } + return out; + } + // ── 응찰(견적서 제출) ────────────────────────────────────────────────────── @Transactional public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) { @@ -195,6 +217,8 @@ public class AuctionService { int myRank = mapper.lowerBidCount(id, total) + 1; Long lowest = lng(mapper.findAuction(id).get("lowestPrice")); + // 순위 변동 → 커밋 후 공개 스냅샷 브로드캐스트(구독자 실시간 갱신 트리거). + realtime.broadcastRanking(id, "BID"); return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat, version, myRank, lowest); } @@ -217,6 +241,8 @@ public class AuctionService { throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨 } mapper.closeAuction(id); + // 마감 → 봉인 상태(sealed=false) 변경을 구독자에게 반영. + realtime.broadcastRanking(id, "CLOSE"); return toSummary(mapper.findAuction(id)); } @@ -262,6 +288,8 @@ public class AuctionService { // 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출. } + // 낙찰 확정 → 상태 전환을 구독자에게 반영(순위 화면 실시간 갱신). + realtime.broadcastRanking(id, "AWARD"); return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt"))); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java index 122d681..24ede30 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java @@ -52,6 +52,19 @@ public final class AuctionDtos { boolean priceMasked, Long price) { } + /** + * 실시간 순위 브로드캐스트 스냅샷(WebSocket {@code /topic/auctions/{id}/ranking}). + *

봉인 안전(구조적 보장): 이 페이로드는 모든 구독자에게 동일하게 전송되므로 + * 뷰어별 개인화 정보(내 순위·내 금액·isMe)를 담지 않는다. ranking 은 {@code isMe=false} 고정으로 + * 마스킹된 공개 순위(현행 폴링이 비응찰 발주자에게 노출하는 수준과 동일 — 최저가 금액만 공개, + * 그 외 타사 금액·업체명 비공개)만 포함한다. 개인화 순위는 클라이언트가 인증된 상세 API로 재조회한다. + * event = BID(응찰 접수/재응찰) | CLOSE(라운드 마감) | AWARD(낙찰). + */ + public record RankingSnapshot( + String auctionId, String eventId, String event, int round, boolean sealed, + int bidderCount, Long lowestPrice, List ranking, String at) { + } + /** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */ public record AuctionDetail( String id, String eventId, String title, String category, String type, String status, diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java index e761299..e5499c2 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java @@ -128,16 +128,35 @@ public interface CmsMapper { """) Map findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo); - // ── 예약 게시(도달분 자동 게시) ──────────────────────────────────────────── - /** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */ + // ── 예약 게시(도달분 자동 게시 — 승인 상태만, @Scheduled 폴러/스윕이 사용) ───── + /** + * 예약 시각 도달·승인(approved) 상태인 예약 콘텐츠의 id/event 목록(스윕 상한). + * ★raw '<' 미사용 — 부팅 크래시 회피 위해 {@code now() >= scheduled_at} 로 표현한다. + */ + @Select(""" + SELECT id, event_id AS "eventId" + FROM cms_content + WHERE scheduled_at IS NOT NULL + AND now() >= scheduled_at + AND status = 'approved' + ORDER BY scheduled_at + LIMIT #{limit} + """) + List> findDueScheduled(@Param("limit") int limit); + + /** + * 예약 게시 단건 전이(멱등·이중 전이 방지) — approved→published 조건 UPDATE. + * 반환=영향 행 수(0=이미 게시됨/전이 불가). WHERE 상태 조건으로 동시/재실행 시 이중 전이를 차단한다. + */ @Update(""" UPDATE cms_content SET status = 'published', published_at = now(), updated_at = now() - WHERE scheduled_at IS NOT NULL - AND scheduled_at <= now() - AND status <> 'published' + WHERE id = #{id} + AND status = 'approved' + AND scheduled_at IS NOT NULL + AND now() >= scheduled_at """) - int publishDueScheduled(); + int publishScheduledDue(@Param("id") String id); // ── 미디어 라이브러리 ────────────────────────────────────────────────────── @Insert(""" diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsScheduledPublisher.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsScheduledPublisher.java new file mode 100644 index 0000000..1ba9016 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsScheduledPublisher.java @@ -0,0 +1,42 @@ +package com.zioinfo.kintex.cms; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * M17 CMS 예약 게시 트리거 — Spring {@code @Scheduled} DB 폴러. + * + *

Redis 지연 큐 대신 DB 폴러를 채택했다: 단일 노드 전제에서 더 단순·결정적이며, 재기동 시 예약이 + * 유실되지 않는다(예약 시각은 {@code cms_content.scheduled_at} 에 영속). 폴러는 30초 주기로 + * {@link CmsService#runScheduledPublish()} 를 호출하며, 실제 전이(상태 조건 UPDATE·버전 스냅샷·감사 로그)는 + * 서비스가 수동 게시와 동일한 경로로 수행한다. + * + *

{@code @Scheduled} 전역 활성화는 기존 {@code WebhookSchedulingConfig(@EnableScheduling)} 가 담당한다. + * 스윕 예외는 조용히 흡수하고 다음 주기에 재시도한다(스택트레이스·민감정보 미노출). + */ +@Component +public class CmsScheduledPublisher { + + private static final Logger log = LoggerFactory.getLogger(CmsScheduledPublisher.class); + + private final CmsService cmsService; + + public CmsScheduledPublisher(CmsService cmsService) { + this.cmsService = cmsService; + } + + /** 예약 게시 폴러 — 기동 30초 후 최초 실행, 이후 30초 고정 지연(이전 스윕 종료 기준). */ + @Scheduled(initialDelay = 30_000, fixedDelay = 30_000) + public void sweep() { + try { + int published = cmsService.runScheduledPublish(); + if (published > 0) { + log.info("M17 CMS 예약 게시 자동 전이 {}건", published); + } + } catch (Exception e) { + log.warn("M17 CMS 예약 게시 스윕 실패: {}", e.getClass().getSimpleName()); + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java index c74ffe3..9d4c354 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java @@ -5,6 +5,7 @@ import com.zioinfo.kintex.ai.AiTextRouter.AiResult; import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.cms.dto.*; import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.audit.AuditLogService; import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.text.HtmlSanitizer; @@ -54,23 +55,30 @@ public class CmsService { private static final Map LANG_NAMES = Map.of( "ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)"); + /** 예약 게시 스윕 1회당 처리 상한(폭주 방지). */ + private static final int SCHED_SWEEP_LIMIT = 200; + /** 예약 게시 감사/버전 행위자(시스템). */ + private static final String SCHED_ACTOR_ID = "system-scheduler"; + private static final String SCHED_ACTOR_NAME = "시스템(예약 게시)"; + private final CmsMapper mapper; private final SystemAccessGuard scope; private final AiTextRouter aiRouter; + private final AuditLogService audit; private final Path uploadRoot; - public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter, + public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter, AuditLogService audit, @Value("${kintex.upload.dir:./data/uploads}") String uploadDir) { this.mapper = mapper; this.scope = scope; this.aiRouter = aiRouter; + this.audit = audit; this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); } // ── 콘텐츠 목록/조회 ─────────────────────────────────────────────────────── public PageResponse list(String eventId, String status, String type, String keyword, int page, int size) { - runScheduledPublish(); int p = Math.max(page, 0); int s = size <= 0 ? 50 : Math.min(size, 200); Map q = new HashMap<>(); @@ -434,20 +442,47 @@ public class CmsService { } // ── 예약 게시 / 공개 조회 ────────────────────────────────────────────────── - /** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */ + /** + * 예약 게시 스윕 — {@code CmsScheduledPublisher} (@Scheduled 폴러)가 주기 호출한다. + *

대상: {@code scheduled_at <= now} AND status='approved' 인 콘텐츠. 각 건을 수동 게시와 동일한 경로로 + * published 전이한다 — 상태 조건 UPDATE(이중 전이 방지) → {@link #snapshot} 버전 스냅샷 → {@link AuditLogService} + * 감사 기록(action=CMS_CONTENT_TRANSITION, 수동 전이와 동일). 전이 로직을 복제하지 않고 재사용한다. + * @return 이번 스윕에서 새로 게시된 건수. + */ @Transactional public int runScheduledPublish() { + List> due; try { - return mapper.publishDueScheduled(); + due = mapper.findDueScheduled(SCHED_SWEEP_LIMIT); } catch (RuntimeException e) { - log.warn("scheduled publish sweep skipped: {}", e.getMessage()); + log.warn("scheduled publish sweep skipped: {}", e.getClass().getSimpleName()); return 0; } + if (due == null || due.isEmpty()) { + return 0; + } + int published = 0; + for (Map row : due) { + String id = str(row.get("id")); + if (id == null) { + continue; + } + // 이중 전이 방지 — 상태 조건 UPDATE(approved→published). 0이면 이미 처리됨 → 스킵. + if (mapper.publishScheduledDue(id) == 0) { + continue; + } + // 수동 게시와 동일하게 버전 스냅샷 + 감사 로그를 남긴다(전이 기록 정합). + snapshot(id, "scheduled-publish", SCHED_ACTOR_NAME); + audit.record(SCHED_ACTOR_ID, SCHED_ACTOR_NAME, "CMS_CONTENT_TRANSITION", + "cms_content", id, str(row.get("eventId")), + "예약 게시 자동 전이(approved → published)", "SUCCESS"); + published++; + } + return published; } - /** 공개 조회(무인증) — 게시 상태만. 조회 시 예약 도달분 자동 게시 후 반환. */ + /** 공개 조회(무인증) — 게시 상태만. 예약 전이는 @Scheduled 폴러가 담당(조회는 순수 읽기). */ public List publicByType(String type, String eventId, int limit) { - runScheduledPublish(); Map q = new HashMap<>(); q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT)); q.put("eventId", blankToNull(eventId)); diff --git a/src/backend/src/main/resources/db/migration/V58__cms_scheduled_publish_index.sql b/src/backend/src/main/resources/db/migration/V58__cms_scheduled_publish_index.sql new file mode 100644 index 0000000..b126deb --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V58__cms_scheduled_publish_index.sql @@ -0,0 +1,8 @@ +-- V58: M17 CMS 예약 게시 폴러 최적화 인덱스 (멱등 · 스키마 컬럼 변경 없음) +-- 근거: CmsScheduledPublisher(@Scheduled 30초 폴러)가 findDueScheduled 를 주기 실행한다 +-- (WHERE scheduled_at IS NOT NULL AND now() >= scheduled_at AND status = 'approved'). +-- cms_content.scheduled_at 컬럼은 V19 에서 이미 존재하므로 신규 컬럼은 없다. 반복 조회를 위한 +-- 부분 인덱스만 추가한다(승인·예약 건만 인덱싱 → 작고 선택도 높음). IF NOT EXISTS 로 멱등. +CREATE INDEX IF NOT EXISTS idx_cms_content_sched_due + ON cms_content (scheduled_at) + WHERE scheduled_at IS NOT NULL AND status = 'approved'; diff --git a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java index 7338fe4..d59cb56 100644 --- a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java +++ b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java @@ -38,7 +38,8 @@ class CmsServiceTest { mapper = mock(CmsMapper.class); scope = mock(SystemAccessGuard.class); aiRouter = mock(AiTextRouter.class); - service = new CmsService(mapper, scope, aiRouter, "./build/test-uploads"); + service = new CmsService(mapper, scope, aiRouter, + mock(com.zioinfo.kintex.common.audit.AuditLogService.class), "./build/test-uploads"); } private Map contentRow(String status) { diff --git a/src/frontend/src/api/endpoints.ts b/src/frontend/src/api/endpoints.ts index 75b697b..9fafcac 100644 --- a/src/frontend/src/api/endpoints.ts +++ b/src/frontend/src/api/endpoints.ts @@ -6,6 +6,7 @@ import { api } from './client'; import type { AcceptInviteRequest, AdminDashboardData, + AdminStats, AnalyticsData, AnalyticsPeriod, AnalyticsPerspective, @@ -257,6 +258,11 @@ export const adminApi = { api.get( `/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`, ), + // SCR-16 실 집계 지표(사용자·행사·테넌트·등록업체·오늘 체크인) — 실 테이블 존재분만. + stats: (tenant?: string) => + api.get( + `/api/admin/stats${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`, + ), }; // ── SCR-16 홀 현장 운영 ── diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index baf97d8..fdc4c76 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -163,6 +163,14 @@ export interface LayoutSummary { violationBlock: number; violationWarn: number; } +/** 홀 마스터 정보(M2 LayoutDto.hall) — 캔버스 축척·경계 원천. dimsM=[폭, 깊이](m). */ +export interface HallInfoDto { + hallId: string; + label?: string | null; + dimsM?: number[] | null; + ceilingM?: number | null; + targetBoothCount?: number | null; +} export interface LayoutDto { layoutId: string; eventId: string; @@ -171,6 +179,7 @@ export interface LayoutDto { name: string; status: string; booths: BoothDto[]; + hall?: HallInfoDto | null; summary: LayoutSummary; updatedAt: string; } @@ -706,6 +715,18 @@ export interface AdminDashboardData { tenants: string[]; } +// ── SCR-16 관리자 집계 지표 (admin.AdminStatsDto) — 실 테이블 존재분만 ── +export interface AdminStats { + totalUsers: number; + activeUsers: number; + totalEvents: number; + ongoingEvents: number; + upcomingEvents: number; + tenantCount: number; + companyCount: number; + todayCheckins: number; +} + // ── SCR-16 홀 현장 운영 (ops.OpsDto) ── export interface OpsHall { hallId: string; diff --git a/src/frontend/src/api/websocket.ts b/src/frontend/src/api/websocket.ts index 915e488..2493b9b 100644 --- a/src/frontend/src/api/websocket.ts +++ b/src/frontend/src/api/websocket.ts @@ -1,14 +1,25 @@ /* - * WebSocket(STOMP over SockJS) — RenderJob 완료 푸시 구독. - * 근거: 계약 §6 — 핸드셰이크 GET /ws(SockJS), 브로드캐스트 prefix /topic, - * 구독 /topic/render/{jobId} → RenderJobDto (design.md §1-4). + * WebSocket(STOMP over SockJS) — 공용 실시간 구독 클라이언트. + * 근거: 계약 §6 — 핸드셰이크 GET /ws(SockJS), 브로드캐스트 prefix /topic. + * - RenderJob 완료: /topic/render/{jobId} (design.md §1-4) + * - 옥션 실시간 순위: /topic/auctions/{auctionId}/ranking (M15 폴링→푸시 전환) + * 단일 STOMP 연결을 refCount 로 공유하고, 연결/재연결/절단을 리스너로 브로드캐스트한다. */ import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import type { RenderJobDto } from './types'; +/** 구독자에게 알리는 연결 상태(옥션 순위 폴백 판단용). */ +export type WsStatus = 'connecting' | 'connected' | 'disconnected'; + +interface ConnListener { + onConnect: () => void; + onDisconnect: () => void; +} + let client: Client | null = null; let refCount = 0; +const connListeners = new Set(); const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`; @@ -20,13 +31,30 @@ function ensureClient(): Client { reconnectDelay: 4000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, - // 조용한 프로덕션 로깅 debug: () => {}, + onConnect: () => connListeners.forEach((l) => l.onConnect()), + onWebSocketClose: () => connListeners.forEach((l) => l.onDisconnect()), + onStompError: () => connListeners.forEach((l) => l.onDisconnect()), }); client.activate(); return client; } +function acquire(): Client { + refCount += 1; + return ensureClient(); +} + +function release(): void { + refCount -= 1; + if (refCount <= 0 && client) { + void client.deactivate(); + client = null; + refCount = 0; + connListeners.clear(); + } +} + /** * 특정 RenderJob 완료/실패 이벤트를 구독한다. * @returns 해제 함수 — 언마운트 시 호출. @@ -35,11 +63,9 @@ export function subscribeRenderJob( jobId: string, onUpdate: (job: RenderJobDto) => void, ): () => void { - const c = ensureClient(); - refCount += 1; - - let sub: StompSubscription | null = null; + const c = acquire(); const topic = `/topic/render/${jobId}`; + let sub: StompSubscription | null = null; const doSubscribe = () => { sub = c.subscribe(topic, (msg: IMessage) => { @@ -51,24 +77,74 @@ export function subscribeRenderJob( }); }; + const listener: ConnListener = { + onConnect: () => { + sub?.unsubscribe(); + doSubscribe(); // 최초 연결·재연결 모두에서 재구독 + }, + onDisconnect: () => { + sub = null; + }, + }; + connListeners.add(listener); + if (c.connected) doSubscribe(); + + return () => { + connListeners.delete(listener); + sub?.unsubscribe(); + release(); + }; +} + +/** + * 옥션 실시간 순위 스냅샷을 구독한다(M15). 서버는 순위 변동(응찰·마감·낙찰) 시 커밋 후 푸시한다. + * 페이로드는 봉인 안전한 공개 순위만 담으므로, 구독자는 이를 재조회 트리거로 쓰고 + * 개인화 순위는 인증 상세 API로 다시 받는 것을 권장한다. + * + * @param onStatus 연결 상태 콜백 — 'disconnected' 시 호출부가 폴링으로 폴백한다. + * @returns 해제 함수. + */ +export function subscribeAuctionRanking( + auctionId: string, + onUpdate: (snapshot: T) => void, + onStatus?: (status: WsStatus) => void, +): () => void { + const c = acquire(); + const topic = `/topic/auctions/${auctionId}/ranking`; + let sub: StompSubscription | null = null; + + const doSubscribe = () => { + sub = c.subscribe(topic, (msg: IMessage) => { + try { + onUpdate(JSON.parse(msg.body) as T); + } catch { + /* 잘못된 페이로드 무시 */ + } + }); + }; + + const listener: ConnListener = { + onConnect: () => { + sub?.unsubscribe(); + doSubscribe(); + onStatus?.('connected'); + }, + onDisconnect: () => { + sub = null; + onStatus?.('disconnected'); + }, + }; + connListeners.add(listener); if (c.connected) { doSubscribe(); + onStatus?.('connected'); } else { - // 연결 후 구독 - const prev = c.onConnect; - c.onConnect = (frame) => { - prev?.(frame); - doSubscribe(); - }; + onStatus?.('connecting'); } return () => { + connListeners.delete(listener); sub?.unsubscribe(); - refCount -= 1; - if (refCount <= 0 && client) { - void client.deactivate(); - client = null; - refCount = 0; - } + release(); }; } diff --git a/src/frontend/src/screens/admin/AdminDashboardPage.tsx b/src/frontend/src/screens/admin/AdminDashboardPage.tsx index 5bf1c6b..a1cf1a5 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -17,14 +17,16 @@ import { IconClock, IconOperations } from '../../components/ui/icons'; import { adminApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { StatusPill } from '../work/workShared'; -import type { AdminDashboardData } from '../../api/types'; +import type { AdminDashboardData, AdminStats } from '../../api/types'; import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin'; import './admin.css'; /* - * SCR-14 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식. - * 정상 경로: GET /api/admin/dashboard?tenant (실집계 — 진행/예정 행사·사용자·라이브 점유). - * 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태. + * SCR-14/16 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식. + * KPI: GET /api/admin/stats (실 집계 — 사용자·행사·테넌트·등록업체·오늘 체크인, 실 테이블 존재분만). + * 라이브/추이: GET /api/admin/dashboard?tenant (진행 행사 점유). 관람객 시간대 추이는 센서 부재 → 빈 상태 유지. + * 실시간 방문객·주차·라이브 IoT 지표는 M10/M14 이후 — 가짜 수치 생성 금지(게이트 유지). + * 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 샘플로 강등(degraded 배너 표기). */ export function AdminDashboardPage() { const { t } = useTranslation(); @@ -36,9 +38,20 @@ export function AdminDashboardPage() { retry: false, }); + const sq = useQuery({ + queryKey: ['admin-stats', tenant], + queryFn: () => adminApi.stats(tenant), + retry: false, + }); + const degraded = isDegradable(q.error); const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null); const hardError = q.isError && !degraded; + + const statsDegraded = isDegradable(sq.error); + const stats: AdminStats | null = sq.data ?? (statsDegraded ? STATS_FALLBACK : null); + const statsHardError = sq.isError && !statsDegraded; + const tenants = data?.tenants?.length ? data.tenants : TENANTS; const activeTenant = tenant ?? tenants[0]; @@ -75,13 +88,39 @@ export function AdminDashboardPage() { ))} - {degraded && ( + {(degraded || statsDegraded) && (

{t('admin.offlineSample')} {t('admin.degradedMsg')}
)} + {/* KPI — 실 집계 지표(GET /api/admin/stats). 실 테이블 존재분만. */} +
+ {sq.isLoading ? ( + <> + + + + + ) : statsHardError ? ( + sq.refetch()} + /> + ) : stats ? ( + buildKpiCards(stats, t).map((k, i) => ( +
+ {k.label} + {k.value} + {k.sub && {k.sub}} +
+ )) + ) : ( + + )} +
+ {q.isLoading && } {hardError && ( @@ -90,20 +129,6 @@ export function AdminDashboardPage() { {data && ( <> -
- {data.kpis.length === 0 ? ( - - ) : ( - data.kpis.map((k, i) => ( -
- {k.label} - {k.value} - {k.sub && {k.sub}} -
- )) - )} -
-
@@ -213,6 +238,60 @@ const FALLBACK: AdminDashboardData = { tenants: TENANTS, }; +/** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */ +const STATS_FALLBACK: AdminStats = { + totalUsers: 0, + activeUsers: 0, + totalEvents: 0, + ongoingEvents: 0, + upcomingEvents: 0, + tenantCount: 0, + companyCount: 0, + todayCheckins: 0, +}; + +type Translate = (key: string, opts?: Record) => string; + +/** + * 실 집계 지표(AdminStats) → KPI 카드. 라벨은 shared 로케일 미수정(t defaultValue 인라인). + * 실시간 방문객·주차·라이브 IoT 지표는 미포함(가짜 수치 금지) — 별도 카드/게이트 유지. + */ +function buildKpiCards(s: AdminStats, t: Translate): { label: string; value: string; sub: string | null }[] { + const n = (v: number) => v.toLocaleString(); + return [ + { + label: t('admin.kpiOngoing', { defaultValue: '진행 행사' }), + value: n(s.ongoingEvents), + sub: t('admin.kpiOngoingSub', { defaultValue: '전체 {{n}}건', n: n(s.totalEvents) }), + }, + { + label: t('admin.kpiUpcoming', { defaultValue: '예정 행사' }), + value: n(s.upcomingEvents), + sub: null, + }, + { + label: t('admin.kpiActiveUsers', { defaultValue: '활성 사용자' }), + value: n(s.activeUsers), + sub: t('admin.kpiActiveUsersSub', { defaultValue: '총 {{n}}명', n: n(s.totalUsers) }), + }, + { + label: t('admin.kpiCompanies', { defaultValue: '등록업체' }), + value: n(s.companyCount), + sub: null, + }, + { + label: t('admin.kpiTodayCheckins', { defaultValue: '오늘 체크인' }), + value: n(s.todayCheckins), + sub: null, + }, + { + label: t('admin.kpiTenants', { defaultValue: '테넌트' }), + value: n(s.tenantCount), + sub: null, + }, + ]; +} + /** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. 첫 항목은 i18n 키. */ const ADMIN_MODULES: [string, string][] = [ ['admin.modUsers', '/admin/users'], diff --git a/src/frontend/src/screens/auction/AuctionDetailPage.tsx b/src/frontend/src/screens/auction/AuctionDetailPage.tsx index 3a4d5fd..85d2e24 100644 --- a/src/frontend/src/screens/auction/AuctionDetailPage.tsx +++ b/src/frontend/src/screens/auction/AuctionDetailPage.tsx @@ -17,9 +17,11 @@ import { auctionApi, formatWon, type AuctionDetail, + type AuctionRankingSnapshot, type MaterialKind, type RankRow, } from './auctionApi'; +import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket'; import { errMessage, useToast } from './aucShared'; import './auction.css'; @@ -51,22 +53,44 @@ export function AuctionDetailPage() { const { auctionId = '' } = useParams(); const navigate = useNavigate(); const qc = useQueryClient(); + const { t } = useTranslation(); const { show, node: toast } = useToast(); const [tab, setTab] = useState('layout'); const [anon, setAnon] = useState(true); const [seconds, setSeconds] = useState(0); const [bidPrice, setBidPrice] = useState(''); const [leadDays, setLeadDays] = useState(''); + // WebSocket 연결 상태 — 'connected' 면 실시간 푸시로 갱신하고 폴링을 저속(폴백)으로 늦춘다. + const [wsStatus, setWsStatus] = useState('connecting'); + const wsLive = wsStatus === 'connected'; const q = useQuery({ queryKey: ['auction', auctionId], queryFn: () => auctionApi.detail(auctionId), enabled: !!auctionId, retry: false, - refetchInterval: 5000, + // 실시간 푸시가 살아 있으면 폴링을 30초 안전망으로 늦추고, 끊기면 즉시 5초 폴백. + refetchInterval: wsLive ? 30000 : 5000, }); const detail = q.data; + // 실시간 순위 구독 — 서버 푸시(응찰·마감·낙찰) 수신 시 인증 상세를 재조회한다. + // ★ 봉인 안전: 푸시 페이로드는 공개 순위만 담고, 개인화 순위(내 순위·금액)는 인증 API 재조회로만 받는다. + useEffect(() => { + if (!auctionId) return; + const unsub = subscribeAuctionRanking( + auctionId, + () => { + qc.invalidateQueries({ queryKey: ['auction', auctionId] }); + }, + setWsStatus, + ); + return () => { + unsub(); + setWsStatus('connecting'); + }; + }, [auctionId, qc]); + // F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회. const boqQ = useQuery({ queryKey: ['auction', auctionId, 'boq'], @@ -227,7 +251,19 @@ export function AuctionDetailPage() { {/* 우 — 실시간 순위·응찰 */}