feat(live-demo): admin stats API, auction realtime ranking push, CMS scheduled publish

- AdminStatsController/AdminStatsDto: real-count admin metrics (SCR-16), SystemAccessGuard.requireAdmin
- AuctionRealtimeService: STOMP /topic/auctions/{id}/ranking broadcast after commit (public ranking only, sealed-bid safe), polling fallback kept
- CmsScheduledPublisher: 30s DB poller -> CmsService.runScheduledPublish (same path as manual publish), V58 partial index (idempotent)
- Frontend: AdminDashboardPage real stats wiring, AuctionDetailPage live ranking via websocket, CmsWorkflowPage schedule UI
- CmsServiceTest: constructor updated for AuditLogService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 22:59:14 +09:00
parent 4fe3d18b7e
commit c686365021
20 changed files with 729 additions and 58 deletions

View File

@ -57,4 +57,32 @@ public interface AdminDashboardMapper {
LIMIT 20 LIMIT 20
""") """)
List<Map<String, Object>> findLiveEvents(@Param("tenantId") String tenantId); List<Map<String, Object>> 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<String, Object> findStatCounts(@Param("tenantId") String tenantId);
} }

View File

@ -1,6 +1,7 @@
package com.zioinfo.kintex.admin; package com.zioinfo.kintex.admin;
import com.zioinfo.kintex.admin.dto.AdminDashboardDto; import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
import com.zioinfo.kintex.tenant.TenantContext; import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -57,6 +58,24 @@ public class AdminDashboardService {
return new AdminDashboardDto(kpis, visitorTrend, live, tenants); return new AdminDashboardDto(kpis, visitorTrend, live, tenants);
} }
/**
* 관리자 집계 지표(SCR-16 실전환) 테이블 존재분만 반환. 테넌트 스코프는 대시보드와 동일(§8-2).
* 실시간 방문객·주차·라이브 IoT 지표는 미포함(M10/M14 이후) 화면에서 정직한 게이트 유지.
*/
public AdminStatsDto getStats(String tenant) {
String tenantId = TenantContext.currentTenantId();
Map<String, Object> 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) { private static long lng(Object o) {
if (o == null) return 0L; if (o == null) return 0L;
if (o instanceof Number n) return n.longValue(); if (o instanceof Number n) return n.longValue();

View File

@ -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<AdminStatsDto> get(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false, defaultValue = "KINTEX") String tenant) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.getStats(tenant));
}
}

View File

@ -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
) {
}

View File

@ -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 푸시 전환).
*
* <p>구독 토픽: {@code /topic/auctions/{auctionId}/ranking} (기존 {@code /topic/render/{jobId}} 컨벤션과 정합).
* 렌더 완료 푸시와 동일한 STOMP 인프라({@link com.zioinfo.kintex.config.WebSocketConfig}) 재사용한다
* 브로커 설정은 신설하지 않는다.
*
* <p><b>봉인 입찰 보안</b>: 페이로드({@link RankingSnapshot}) 모든 구독자에게 동일 전송되므로
* {@link AuctionService#publicRanking}(뷰어별 개인화 없음·최저가만 공개) 담는다. 개인화 순위( 순위·금액)
* 클라이언트가 인증된 상세 API로 재조회하여 서버 마스킹을 그대로 적용받는다.
*
* <p><b>트랜잭션 경계</b>: 순위 변동 트랜잭션이 <b>커밋된 뒤에만</b> 전송한다(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<String, Object> a = mapper.findAuction(auctionId);
if (a == null) {
return null;
}
boolean closed = bool(a.get("closed"));
List<RankRow> 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;
}
}
}

View File

@ -37,17 +37,20 @@ public class AuctionService {
private final com.zioinfo.kintex.work.notification.NotificationService notificationService; private final com.zioinfo.kintex.work.notification.NotificationService notificationService;
private final com.zioinfo.kintex.mail.MailService mailService; private final com.zioinfo.kintex.mail.MailService mailService;
private final com.zioinfo.kintex.mail.MailProperties mailProperties; private final com.zioinfo.kintex.mail.MailProperties mailProperties;
private final AuctionRealtimeService realtime;
public AuctionService(AuctionMapper mapper, public AuctionService(AuctionMapper mapper,
com.zioinfo.kintex.settlement.SettlementService settlementService, com.zioinfo.kintex.settlement.SettlementService settlementService,
com.zioinfo.kintex.work.notification.NotificationService notificationService, com.zioinfo.kintex.work.notification.NotificationService notificationService,
com.zioinfo.kintex.mail.MailService mailService, com.zioinfo.kintex.mail.MailService mailService,
com.zioinfo.kintex.mail.MailProperties mailProperties) { com.zioinfo.kintex.mail.MailProperties mailProperties,
AuctionRealtimeService realtime) {
this.mapper = mapper; this.mapper = mapper;
this.settlementService = settlementService; this.settlementService = settlementService;
this.notificationService = notificationService; this.notificationService = notificationService;
this.mailService = mailService; this.mailService = mailService;
this.mailProperties = mailProperties; this.mailProperties = mailProperties;
this.realtime = realtime;
} }
// 목록(SCR-26) // 목록(SCR-26)
@ -128,6 +131,25 @@ public class AuctionService {
return out; return out;
} }
/**
* 공개 순위(브로드캐스트용). {@link #buildRanking} myCompanyId=null·sealed 호출한 것과 동일
* <b>비응찰 뷰어가 폴링으로 보는 순위</b>(최저가 금액만 공개, 타사 금액·업체명 마스킹).
* 뷰어별 개인화(isMe· 금액) 절대 포함하지 않으므로 모든 구독자에게 전송해도 봉인 규칙을 위반하지 않는다.
*/
static List<RankRow> publicRanking(List<Map<String, Object>> rows) {
List<RankRow> out = new ArrayList<>();
int rank = 0;
for (Map<String, Object> 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 @Transactional
public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) { public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) {
@ -195,6 +217,8 @@ public class AuctionService {
int myRank = mapper.lowerBidCount(id, total) + 1; int myRank = mapper.lowerBidCount(id, total) + 1;
Long lowest = lng(mapper.findAuction(id).get("lowestPrice")); 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, return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat,
version, myRank, lowest); version, myRank, lowest);
} }
@ -217,6 +241,8 @@ public class AuctionService {
throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨 throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨
} }
mapper.closeAuction(id); mapper.closeAuction(id);
// 마감 봉인 상태(sealed=false) 변경을 구독자에게 반영.
realtime.broadcastRanking(id, "CLOSE");
return toSummary(mapper.findAuction(id)); 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"))); return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
} }

View File

@ -52,6 +52,19 @@ public final class AuctionDtos {
boolean priceMasked, Long price) { boolean priceMasked, Long price) {
} }
/**
* 실시간 순위 브로드캐스트 스냅샷(WebSocket {@code /topic/auctions/{id}/ranking}).
* <p><b>봉인 안전(구조적 보장)</b>: 페이로드는 모든 구독자에게 동일하게 전송되므로
* <b>뷰어별 개인화 정보( 순위· 금액·isMe) 담지 않는다</b>. ranking {@code isMe=false} 고정으로
* 마스킹된 <b>공개 순위</b>(현행 폴링이 비응찰 발주자에게 노출하는 수준과 동일 최저가 금액만 공개,
* 타사 금액·업체명 비공개) 포함한다. 개인화 순위는 클라이언트가 인증된 상세 API로 재조회한다.
* event = BID(응찰 접수/재응찰) | CLOSE(라운드 마감) | AWARD(낙찰).
*/
public record RankingSnapshot(
String auctionId, String eventId, String event, int round, boolean sealed,
int bidderCount, Long lowestPrice, List<RankRow> ranking, String at) {
}
/** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */ /** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */
public record AuctionDetail( public record AuctionDetail(
String id, String eventId, String title, String category, String type, String status, String id, String eventId, String title, String category, String type, String status,

View File

@ -128,16 +128,35 @@ public interface CmsMapper {
""") """)
Map<String, Object> findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo); Map<String, Object> findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo);
// 예약 게시(도달분 자동 게시) // 예약 게시(도달분 자동 게시 승인 상태만, @Scheduled 폴러/스윕이 사용)
/** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */ /**
* 예약 시각 도달·승인(approved) 상태인 예약 콘텐츠의 id/event 목록(스윕 상한).
* raw '&lt;' 미사용 부팅 크래시 회피 위해 {@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<Map<String, Object>> findDueScheduled(@Param("limit") int limit);
/**
* 예약 게시 단건 전이(멱등·이중 전이 방지) approvedpublished 조건 UPDATE.
* 반환=영향 (0=이미 게시됨/전이 불가). WHERE 상태 조건으로 동시/재실행 이중 전이를 차단한다.
*/
@Update(""" @Update("""
UPDATE cms_content UPDATE cms_content
SET status = 'published', published_at = now(), updated_at = now() SET status = 'published', published_at = now(), updated_at = now()
WHERE scheduled_at IS NOT NULL WHERE id = #{id}
AND scheduled_at <= now() AND status = 'approved'
AND status <> 'published' AND scheduled_at IS NOT NULL
AND now() >= scheduled_at
""") """)
int publishDueScheduled(); int publishScheduledDue(@Param("id") String id);
// 미디어 라이브러리 // 미디어 라이브러리
@Insert(""" @Insert("""

View File

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

View File

@ -5,6 +5,7 @@ import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.*; import com.zioinfo.kintex.cms.dto.*;
import com.zioinfo.kintex.common.PageResponse; 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.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.common.text.HtmlSanitizer; import com.zioinfo.kintex.common.text.HtmlSanitizer;
@ -54,23 +55,30 @@ public class CmsService {
private static final Map<String, String> LANG_NAMES = Map.of( private static final Map<String, String> LANG_NAMES = Map.of(
"ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)"); "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 CmsMapper mapper;
private final SystemAccessGuard scope; private final SystemAccessGuard scope;
private final AiTextRouter aiRouter; private final AiTextRouter aiRouter;
private final AuditLogService audit;
private final Path uploadRoot; 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) { @Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
this.mapper = mapper; this.mapper = mapper;
this.scope = scope; this.scope = scope;
this.aiRouter = aiRouter; this.aiRouter = aiRouter;
this.audit = audit;
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
} }
// 콘텐츠 목록/조회 // 콘텐츠 목록/조회
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword, public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
int page, int size) { int page, int size) {
runScheduledPublish();
int p = Math.max(page, 0); int p = Math.max(page, 0);
int s = size <= 0 ? 50 : Math.min(size, 200); int s = size <= 0 ? 50 : Math.min(size, 200);
Map<String, Object> q = new HashMap<>(); Map<String, Object> q = new HashMap<>();
@ -434,20 +442,47 @@ public class CmsService {
} }
// 예약 게시 / 공개 조회 // 예약 게시 / 공개 조회
/** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */ /**
* 예약 게시 스윕 {@code CmsScheduledPublisher} (@Scheduled 폴러) 주기 호출한다.
* <p>대상: {@code scheduled_at <= now} AND status='approved' 콘텐츠. 건을 <b>수동 게시와 동일한 경로</b>
* published 전이한다 상태 조건 UPDATE(이중 전이 방지) {@link #snapshot} 버전 스냅샷 {@link AuditLogService}
* 감사 기록(action=CMS_CONTENT_TRANSITION, 수동 전이와 동일). 전이 로직을 복제하지 않고 재사용한다.
* @return 이번 스윕에서 새로 게시된 건수.
*/
@Transactional @Transactional
public int runScheduledPublish() { public int runScheduledPublish() {
List<Map<String, Object>> due;
try { try {
return mapper.publishDueScheduled(); due = mapper.findDueScheduled(SCHED_SWEEP_LIMIT);
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("scheduled publish sweep skipped: {}", e.getMessage()); log.warn("scheduled publish sweep skipped: {}", e.getClass().getSimpleName());
return 0; return 0;
} }
if (due == null || due.isEmpty()) {
return 0;
}
int published = 0;
for (Map<String, Object> row : due) {
String id = str(row.get("id"));
if (id == null) {
continue;
}
// 이중 전이 방지 상태 조건 UPDATE(approvedpublished). 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<CmsContentDto> publicByType(String type, String eventId, int limit) { public List<CmsContentDto> publicByType(String type, String eventId, int limit) {
runScheduledPublish();
Map<String, Object> q = new HashMap<>(); Map<String, Object> q = new HashMap<>();
q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT)); q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
q.put("eventId", blankToNull(eventId)); q.put("eventId", blankToNull(eventId));

View File

@ -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';

View File

@ -38,7 +38,8 @@ class CmsServiceTest {
mapper = mock(CmsMapper.class); mapper = mock(CmsMapper.class);
scope = mock(SystemAccessGuard.class); scope = mock(SystemAccessGuard.class);
aiRouter = mock(AiTextRouter.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<String, Object> contentRow(String status) { private Map<String, Object> contentRow(String status) {

View File

@ -6,6 +6,7 @@ import { api } from './client';
import type { import type {
AcceptInviteRequest, AcceptInviteRequest,
AdminDashboardData, AdminDashboardData,
AdminStats,
AnalyticsData, AnalyticsData,
AnalyticsPeriod, AnalyticsPeriod,
AnalyticsPerspective, AnalyticsPerspective,
@ -257,6 +258,11 @@ export const adminApi = {
api.get<AdminDashboardData>( api.get<AdminDashboardData>(
`/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`, `/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
), ),
// SCR-16 실 집계 지표(사용자·행사·테넌트·등록업체·오늘 체크인) — 실 테이블 존재분만.
stats: (tenant?: string) =>
api.get<AdminStats>(
`/api/admin/stats${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
),
}; };
// ── SCR-16 홀 현장 운영 ── // ── SCR-16 홀 현장 운영 ──

View File

@ -163,6 +163,14 @@ export interface LayoutSummary {
violationBlock: number; violationBlock: number;
violationWarn: 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 { export interface LayoutDto {
layoutId: string; layoutId: string;
eventId: string; eventId: string;
@ -171,6 +179,7 @@ export interface LayoutDto {
name: string; name: string;
status: string; status: string;
booths: BoothDto[]; booths: BoothDto[];
hall?: HallInfoDto | null;
summary: LayoutSummary; summary: LayoutSummary;
updatedAt: string; updatedAt: string;
} }
@ -706,6 +715,18 @@ export interface AdminDashboardData {
tenants: string[]; 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) ── // ── SCR-16 홀 현장 운영 (ops.OpsDto) ──
export interface OpsHall { export interface OpsHall {
hallId: string; hallId: string;

View File

@ -1,14 +1,25 @@
/* /*
* WebSocket(STOMP over SockJS) RenderJob . * WebSocket(STOMP over SockJS) .
* 근거: 계약 §6 GET /ws(SockJS), prefix /topic, * 근거: 계약 §6 GET /ws(SockJS), prefix /topic.
* /topic/render/{jobId} RenderJobDto (design.md §1-4). * - 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 { Client, type IMessage, type StompSubscription } from '@stomp/stompjs';
import SockJS from 'sockjs-client'; import SockJS from 'sockjs-client';
import type { RenderJobDto } from './types'; import type { RenderJobDto } from './types';
/** 구독자에게 알리는 연결 상태(옥션 순위 폴백 판단용). */
export type WsStatus = 'connecting' | 'connected' | 'disconnected';
interface ConnListener {
onConnect: () => void;
onDisconnect: () => void;
}
let client: Client | null = null; let client: Client | null = null;
let refCount = 0; let refCount = 0;
const connListeners = new Set<ConnListener>();
const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`; const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`;
@ -20,13 +31,30 @@ function ensureClient(): Client {
reconnectDelay: 4000, reconnectDelay: 4000,
heartbeatIncoming: 10000, heartbeatIncoming: 10000,
heartbeatOutgoing: 10000, heartbeatOutgoing: 10000,
// 조용한 프로덕션 로깅
debug: () => {}, debug: () => {},
onConnect: () => connListeners.forEach((l) => l.onConnect()),
onWebSocketClose: () => connListeners.forEach((l) => l.onDisconnect()),
onStompError: () => connListeners.forEach((l) => l.onDisconnect()),
}); });
client.activate(); client.activate();
return client; 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 / . * RenderJob / .
* @returns . * @returns .
@ -35,11 +63,9 @@ export function subscribeRenderJob(
jobId: string, jobId: string,
onUpdate: (job: RenderJobDto) => void, onUpdate: (job: RenderJobDto) => void,
): () => void { ): () => void {
const c = ensureClient(); const c = acquire();
refCount += 1;
let sub: StompSubscription | null = null;
const topic = `/topic/render/${jobId}`; const topic = `/topic/render/${jobId}`;
let sub: StompSubscription | null = null;
const doSubscribe = () => { const doSubscribe = () => {
sub = c.subscribe(topic, (msg: IMessage) => { sub = c.subscribe(topic, (msg: IMessage) => {
@ -51,24 +77,74 @@ export function subscribeRenderJob(
}); });
}; };
if (c.connected) { const listener: ConnListener = {
doSubscribe(); onConnect: () => {
} else { sub?.unsubscribe();
// 연결 후 구독 doSubscribe(); // 최초 연결·재연결 모두에서 재구독
const prev = c.onConnect; },
c.onConnect = (frame) => { onDisconnect: () => {
prev?.(frame); sub = null;
doSubscribe(); },
};
connListeners.add(listener);
if (c.connected) doSubscribe();
return () => {
connListeners.delete(listener);
sub?.unsubscribe();
release();
}; };
} }
return () => { /**
sub?.unsubscribe(); * (M15). (··) .
refCount -= 1; * , <b> </b>
if (refCount <= 0 && client) { * API로 .
void client.deactivate(); *
client = null; * @param onStatus 'disconnected' .
refCount = 0; * @returns .
*/
export function subscribeAuctionRanking<T>(
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 {
onStatus?.('connecting');
}
return () => {
connListeners.delete(listener);
sub?.unsubscribe();
release();
}; };
} }

View File

@ -17,14 +17,16 @@ import { IconClock, IconOperations } from '../../components/ui/icons';
import { adminApi } from '../../api/endpoints'; import { adminApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { StatusPill } from '../work/workShared'; 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_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin';
import './admin.css'; import './admin.css';
/* /*
* SCR-14 (M18 ). Stitch admin_dashboard . * SCR-14/16 (M18 ). Stitch admin_dashboard .
* 경로: GET /api/admin/dashboard?tenant ( / ·· ). * KPI: GET /api/admin/stats ( ···· , ).
* 폴백: NETWORK/NOT_FOUND sampleAdmin . . * /추이: GET /api/admin/dashboard?tenant ( ). .
* ·· IoT M10/M14 ( ).
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED (degraded ).
*/ */
export function AdminDashboardPage() { export function AdminDashboardPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -36,9 +38,20 @@ export function AdminDashboardPage() {
retry: false, retry: false,
}); });
const sq = useQuery({
queryKey: ['admin-stats', tenant],
queryFn: () => adminApi.stats(tenant),
retry: false,
});
const degraded = isDegradable(q.error); const degraded = isDegradable(q.error);
const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null); const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null);
const hardError = q.isError && !degraded; 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 tenants = data?.tenants?.length ? data.tenants : TENANTS;
const activeTenant = tenant ?? tenants[0]; const activeTenant = tenant ?? tenants[0];
@ -75,13 +88,39 @@ export function AdminDashboardPage() {
))} ))}
</nav> </nav>
{degraded && ( {(degraded || statsDegraded) && (
<div className="kx-admin__degraded-bar"> <div className="kx-admin__degraded-bar">
<span className="kx-bi__degraded">{t('admin.offlineSample')}</span> <span className="kx-bi__degraded">{t('admin.offlineSample')}</span>
{t('admin.degradedMsg')} {t('admin.degradedMsg')}
</div> </div>
)} )}
{/* KPI — 실 집계 지표(GET /api/admin/stats). 실 테이블 존재분만. */}
<section className="kx-admin__kpis" aria-label={t('admin.kpisAria')}>
{sq.isLoading ? (
<>
<Skeleton height={92} radius={12} />
<Skeleton height={92} radius={12} />
<Skeleton height={92} radius={12} />
</>
) : statsHardError ? (
<ErrorState
message={t('admin.statsLoadError', { defaultValue: '집계 지표를 불러오지 못했습니다.' })}
onRetry={() => sq.refetch()}
/>
) : stats ? (
buildKpiCards(stats, t).map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))
) : (
<EmptyState title={t('admin.kpiEmpty')} />
)}
</section>
{q.isLoading && <AdminSkeleton />} {q.isLoading && <AdminSkeleton />}
{hardError && ( {hardError && (
@ -90,20 +129,6 @@ export function AdminDashboardPage() {
{data && ( {data && (
<> <>
<section className="kx-admin__kpis" aria-label={t('admin.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title={t('admin.kpiEmpty')} />
) : (
data.kpis.map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
<span className="kx-kpi__label">{k.label}</span>
<strong className="kx-kpi__value tnum">{k.value}</strong>
{k.sub && <span className="kx-kpi__sub">{k.sub}</span>}
</div>
))
)}
</section>
<div className="kx-admin__split"> <div className="kx-admin__split">
<section className="kx-card kx-admin__chart-card" aria-label={t('admin.visitorTrend')}> <section className="kx-card kx-admin__chart-card" aria-label={t('admin.visitorTrend')}>
<div className="kx-card__head"> <div className="kx-card__head">
@ -213,6 +238,60 @@ const FALLBACK: AdminDashboardData = {
tenants: TENANTS, 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, unknown>) => 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 키. */ /** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. 첫 항목은 i18n 키. */
const ADMIN_MODULES: [string, string][] = [ const ADMIN_MODULES: [string, string][] = [
['admin.modUsers', '/admin/users'], ['admin.modUsers', '/admin/users'],

View File

@ -17,9 +17,11 @@ import {
auctionApi, auctionApi,
formatWon, formatWon,
type AuctionDetail, type AuctionDetail,
type AuctionRankingSnapshot,
type MaterialKind, type MaterialKind,
type RankRow, type RankRow,
} from './auctionApi'; } from './auctionApi';
import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket';
import { errMessage, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import './auction.css'; import './auction.css';
@ -51,22 +53,44 @@ export function AuctionDetailPage() {
const { auctionId = '' } = useParams(); const { auctionId = '' } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation();
const { show, node: toast } = useToast(); const { show, node: toast } = useToast();
const [tab, setTab] = useState<MaterialKind>('layout'); const [tab, setTab] = useState<MaterialKind>('layout');
const [anon, setAnon] = useState(true); const [anon, setAnon] = useState(true);
const [seconds, setSeconds] = useState(0); const [seconds, setSeconds] = useState(0);
const [bidPrice, setBidPrice] = useState(''); const [bidPrice, setBidPrice] = useState('');
const [leadDays, setLeadDays] = useState(''); const [leadDays, setLeadDays] = useState('');
// WebSocket 연결 상태 — 'connected' 면 실시간 푸시로 갱신하고 폴링을 저속(폴백)으로 늦춘다.
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const wsLive = wsStatus === 'connected';
const q = useQuery({ const q = useQuery({
queryKey: ['auction', auctionId], queryKey: ['auction', auctionId],
queryFn: () => auctionApi.detail(auctionId), queryFn: () => auctionApi.detail(auctionId),
enabled: !!auctionId, enabled: !!auctionId,
retry: false, retry: false,
refetchInterval: 5000, // 실시간 푸시가 살아 있으면 폴링을 30초 안전망으로 늦추고, 끊기면 즉시 5초 폴백.
refetchInterval: wsLive ? 30000 : 5000,
}); });
const detail = q.data; const detail = q.data;
// 실시간 순위 구독 — 서버 푸시(응찰·마감·낙찰) 수신 시 인증 상세를 재조회한다.
// ★ 봉인 안전: 푸시 페이로드는 공개 순위만 담고, 개인화 순위(내 순위·금액)는 인증 API 재조회로만 받는다.
useEffect(() => {
if (!auctionId) return;
const unsub = subscribeAuctionRanking<AuctionRankingSnapshot>(
auctionId,
() => {
qc.invalidateQueries({ queryKey: ['auction', auctionId] });
},
setWsStatus,
);
return () => {
unsub();
setWsStatus('connecting');
};
}, [auctionId, qc]);
// F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회. // F035 물량서(BOQ) — 자료 탭이 boq 일 때만 서버 규칙 기반 산출 조회.
const boqQ = useQuery({ const boqQ = useQuery({
queryKey: ['auction', auctionId, 'boq'], queryKey: ['auction', auctionId, 'boq'],
@ -227,7 +251,19 @@ export function AuctionDetailPage() {
{/* 우 — 실시간 순위·응찰 */} {/* 우 — 실시간 순위·응찰 */}
<aside className="kx-rank" aria-label="실시간 순위"> <aside className="kx-rank" aria-label="실시간 순위">
<div className="kx-rank__head"> <div className="kx-rank__head">
<h2> </h2> <h2>
{/* 소극적 연결 표시 — 실시간 푸시 연결 시 옅은 점, 폴백(폴링) 시 숨김성 회색. */}
<span
className={`kx-rank__ws ${wsLive ? 'is-live' : 'is-poll'}`}
aria-hidden="true"
title={
wsLive
? t('auction.rank.wsLive', { defaultValue: '실시간 갱신 중' })
: t('auction.rank.wsPoll', { defaultValue: '주기적 갱신 중' })
}
/>
</h2>
<button <button
type="button" type="button"
className="kx-toggle" className="kx-toggle"

View File

@ -559,6 +559,26 @@
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
/* 소극적 실시간 연결 표시 — 과시 금지: 작은 점, 폴백 시 옅은 회색. */
.kx-rank__ws {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
background: var(--color-neutral-300);
}
.kx-rank__ws.is-live {
background: var(--color-success, #16a34a);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
animation: kx-rank-ws-pulse 2.4s ease-in-out infinite;
}
@keyframes kx-rank-ws-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.kx-rank__ws.is-live { animation: none; }
}
.kx-toggle { .kx-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -81,6 +81,23 @@ export interface AuctionDetail {
finalPrice: number | null; finalPrice: number | null;
} }
/**
* WebSocket (/topic/auctions/{id}/ranking).
* 안전: 서버가 ( · ·isMe) .
* , API(detail) .
*/
export interface AuctionRankingSnapshot {
auctionId: string;
eventId: string;
event: 'BID' | 'CLOSE' | 'AWARD';
round: number;
sealed: boolean;
bidderCount: number;
lowestPrice: number | null;
ranking: RankRow[];
at: string;
}
export interface BidResult { export interface BidResult {
id: string; id: string;
round: number; round: number;

View File

@ -48,6 +48,15 @@ function fmtDate(iso: string | null): string {
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' }); return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' });
} }
// ISO(UTC, ...Z) → datetime-local 입력값(yyyy-MM-ddTHH:mm, 로컬 시각). 예약 게시 시각 편집용.
function toLocalInput(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function CmsWorkflowPage() { export function CmsWorkflowPage() {
const [items, setItems] = useState<CmsContent[]>([]); const [items, setItems] = useState<CmsContent[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@ -56,6 +65,8 @@ export function CmsWorkflowPage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [signage, setSignage] = useState(true); const [signage, setSignage] = useState(true);
const [mailing, setMailing] = useState(false); const [mailing, setMailing] = useState(false);
const [schedInput, setSchedInput] = useState('');
const [schedSaving, setSchedSaving] = useState(false);
async function reload(keepSelection = true) { async function reload(keepSelection = true) {
setLoading(true); setLoading(true);
@ -83,6 +94,27 @@ export function CmsWorkflowPage() {
[items, selectedId], [items, selectedId],
); );
// 선택 콘텐츠가 바뀌면 예약 시각 입력을 서버 값으로 동기화.
useEffect(() => {
setSchedInput(toLocalInput(selected?.scheduledAt ?? null));
}, [selectedId, selected?.scheduledAt]);
// 예약 게시 시각 저장(비우면 예약 해제). PUT /api/cms/contents/{id} — 제목은 서버 필수라 현재 제목을 함께 전송.
async function saveSchedule() {
if (!selected) return;
setSchedSaving(true);
setError(null);
try {
const iso = schedInput ? new Date(schedInput).toISOString() : null;
const updated = await cmsApi.updateContent(selected.id, { title: selected.title, scheduledAt: iso });
setItems((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '예약 시각 저장에 실패했습니다.');
} finally {
setSchedSaving(false);
}
}
async function advance(next: FlowStatus) { async function advance(next: FlowStatus) {
if (!selected) return; if (!selected) return;
setBusy(true); setBusy(true);
@ -231,12 +263,30 @@ export function CmsWorkflowPage() {
</div> </div>
<div> <div>
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}> <div className="kx-cms-sublabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{selected?.scheduledAt && selected.status !== 'published' && (
<span className="kx-cms-pill kx-cms-pill--scheduled"></span>
)}
</div> </div>
<div className="kx-cms-sched"> <div className="kx-cms-sched">
<IconCalendar size={18} /> <IconCalendar size={18} />
<span>{selected?.scheduledAt ? fmtDate(selected.scheduledAt) : '예약 없음'}</span> <input
type="datetime-local"
className="kx-cms-sched__input"
value={schedInput}
onChange={(e) => setSchedInput(e.target.value)}
aria-label="예약 게시 시각"
disabled={!selected || schedSaving}
/>
<Button variant="secondary" onClick={saveSchedule} disabled={!selected || schedSaving}>
{schedSaving ? '저장 중…' : '예약 저장'}
</Button>
</div>
<div className="kx-cms-sched__hint">
{selected?.scheduledAt
? `예약: ${fmtDate(selected.scheduledAt)} — 승인(approved) 상태이면 시각 도달 시 자동 게시됩니다.`
: '시각을 지정하면, 승인 후 예약 시각에 자동 게시됩니다. 비우고 저장하면 예약이 해제됩니다.'}
</div> </div>
</div> </div>
</section> </section>