Compare commits
2 Commits
4fe3d18b7e
...
0cc7f26c56
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc7f26c56 | |||
| c686365021 |
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 '<' 미사용 — 부팅 크래시 회피 위해 {@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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예약 게시 단건 전이(멱등·이중 전이 방지) — approved→published 조건 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("""
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(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<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));
|
||||||
|
|||||||
@ -353,7 +353,12 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
|
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */
|
/**
|
||||||
|
* 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율에 더해 요청 조건
|
||||||
|
* (주출입구·무대·라운지 수)을 <b>예약 영역</b>으로 반영해 목표 수까지 배치한다.
|
||||||
|
* 예약 영역(중앙 교차 통로·출입구 전면 클리어존·무대·라운지)과 겹치는 셀은 건너뛰어
|
||||||
|
* 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
|
||||||
|
*/
|
||||||
private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
|
private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
|
||||||
double hallW, double hallD, AutoLayoutRequest req, char tag) {
|
double hallW, double hallD, AutoLayoutRequest req, char tag) {
|
||||||
List<BoothDto> booths = new ArrayList<>();
|
List<BoothDto> booths = new ArrayList<>();
|
||||||
@ -364,9 +369,14 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
|
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
|
||||||
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
|
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
|
||||||
|
|
||||||
|
List<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
|
||||||
|
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) {
|
for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) {
|
||||||
for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) {
|
for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) {
|
||||||
|
if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
index++;
|
index++;
|
||||||
boolean premium = index <= premiumTarget;
|
boolean premium = index <= premiumTarget;
|
||||||
String boothNo = String.format("%c-%03d", tag, index);
|
String boothNo = String.format("%c-%03d", tag, index);
|
||||||
@ -379,6 +389,59 @@ public class FloorplanServiceImpl implements FloorplanService {
|
|||||||
return booths;
|
return booths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 조건 기반 예약 영역 [x0,y0,x1,y1] 목록(홀 로컬 m) — 결정적 산출.
|
||||||
|
* <ul>
|
||||||
|
* <li>중앙 교차 주통로: 홀 폭 40m 이상이면 세로, 깊이 40m 이상이면 가로(폭 max(1.5×통로, 4.5m))</li>
|
||||||
|
* <li>주출입구 클리어존: 전면(y=0) 변에 균등 분포, 개소당 9m 폭 × 6m 깊이</li>
|
||||||
|
* <li>무대: 후면(y=hallD) 중앙부에 12×9m + 사방 3m 버퍼</li>
|
||||||
|
* <li>라운지: 홀 중앙부에 9×9m + 사방 3m 버퍼</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private List<double[]> reservedZones(double hallW, double hallD, double aisle, AutoLayoutRequest req) {
|
||||||
|
List<double[]> zones = new ArrayList<>();
|
||||||
|
double mainAisle = Math.max(aisle * 1.5, 4.5);
|
||||||
|
|
||||||
|
if (hallW >= 40) {
|
||||||
|
double cx = hallW / 2;
|
||||||
|
zones.add(new double[]{cx - mainAisle / 2, 0, cx + mainAisle / 2, hallD});
|
||||||
|
}
|
||||||
|
if (hallD >= 40) {
|
||||||
|
double cy = hallD / 2;
|
||||||
|
zones.add(new double[]{0, cy - mainAisle / 2, hallW, cy + mainAisle / 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
int entrances = Math.max(0, req.mainEntranceCount());
|
||||||
|
for (int i = 1; i <= entrances; i++) {
|
||||||
|
double ex = hallW * i / (entrances + 1.0);
|
||||||
|
zones.add(new double[]{ex - 4.5, 0, ex + 4.5, PERIMETER_MARGIN_M + 6.0});
|
||||||
|
}
|
||||||
|
|
||||||
|
int stages = Math.max(0, req.stageCount());
|
||||||
|
for (int i = 1; i <= stages; i++) {
|
||||||
|
double sx = hallW * i / (stages + 1.0);
|
||||||
|
double sy = hallD - PERIMETER_MARGIN_M - 9.0;
|
||||||
|
zones.add(new double[]{sx - 6.0 - 3.0, sy - 3.0, sx + 6.0 + 3.0, hallD});
|
||||||
|
}
|
||||||
|
|
||||||
|
int lounges = Math.max(0, req.loungeCount());
|
||||||
|
for (int i = 1; i <= lounges; i++) {
|
||||||
|
double lx = hallW * i / (lounges + 1.0);
|
||||||
|
double ly = hallD / 2;
|
||||||
|
zones.add(new double[]{lx - 4.5 - 3.0, ly - 4.5 - 3.0, lx + 4.5 + 3.0, ly + 4.5 + 3.0});
|
||||||
|
}
|
||||||
|
return zones;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean intersectsAny(List<double[]> zones, double x0, double y0, double x1, double y1) {
|
||||||
|
for (double[] z : zones) {
|
||||||
|
if (x0 < z[2] && x1 > z[0] && y0 < z[3] && y1 > z[1]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
|
/** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
|
||||||
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
|
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -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';
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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 홀 현장 운영 ──
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
|
|||||||
import { AutoLayoutDialog } from './AutoLayoutDialog';
|
import { AutoLayoutDialog } from './AutoLayoutDialog';
|
||||||
import { ValidationPanel } from './ValidationPanel';
|
import { ValidationPanel } from './ValidationPanel';
|
||||||
import { sampleLayout, emptyLayout } from './sampleLayout';
|
import { sampleLayout, emptyLayout } from './sampleLayout';
|
||||||
import { hallFloorplanUrl } from './hallFloorplan';
|
import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan';
|
||||||
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
|
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
|
||||||
import './editor.css';
|
import './editor.css';
|
||||||
|
|
||||||
@ -173,7 +173,11 @@ export function BoothLayoutEditorPage() {
|
|||||||
) : (
|
) : (
|
||||||
layout && (
|
layout && (
|
||||||
<FloorplanCanvas
|
<FloorplanCanvas
|
||||||
hallDims={[126, 90]}
|
hallDims={
|
||||||
|
layout.hall?.dimsM && layout.hall.dimsM.length >= 2
|
||||||
|
? [layout.hall.dimsM[0], layout.hall.dimsM[1]]
|
||||||
|
: [126, 90]
|
||||||
|
}
|
||||||
booths={layout.booths}
|
booths={layout.booths}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
selectedBoothId={selectedBoothId}
|
selectedBoothId={selectedBoothId}
|
||||||
@ -181,6 +185,7 @@ export function BoothLayoutEditorPage() {
|
|||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
floorplanUrl={hallFloorplanUrl(hallId)}
|
floorplanUrl={hallFloorplanUrl(hallId)}
|
||||||
|
floorRegion={hallFloorRegion(hallId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -29,6 +29,12 @@ interface FloorplanCanvasProps {
|
|||||||
zoom: number;
|
zoom: number;
|
||||||
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
|
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
|
||||||
floorplanUrl?: string | null;
|
floorplanUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* 도면 이미지 내 전시 바닥면 분율 영역 [x0,y0,x1,y1] (hallFloorplan.ts 캘리브레이션).
|
||||||
|
* 지정 시 이 영역이 홀 사각형(0,0~hw,hh)에 정렬되도록 이미지를 확대 배치 —
|
||||||
|
* 부스 좌표(홀 로컬 m)가 도면의 채색 바닥면 "안"에 놓인다. 미지정 시 전체 스트레치(구 동작).
|
||||||
|
*/
|
||||||
|
floorRegion?: [number, number, number, number] | null;
|
||||||
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
|
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
|
||||||
onZoomChange?: (zoom: number) => void;
|
onZoomChange?: (zoom: number) => void;
|
||||||
}
|
}
|
||||||
@ -55,9 +61,26 @@ export function FloorplanCanvas({
|
|||||||
onSelectBooth,
|
onSelectBooth,
|
||||||
zoom,
|
zoom,
|
||||||
floorplanUrl,
|
floorplanUrl,
|
||||||
|
floorRegion,
|
||||||
onZoomChange,
|
onZoomChange,
|
||||||
}: FloorplanCanvasProps) {
|
}: FloorplanCanvasProps) {
|
||||||
const [hw, hh] = hallDims;
|
const [hw, hh] = hallDims;
|
||||||
|
|
||||||
|
// 도면 배치: 캘리브레이션 영역이 있으면 바닥면(fx0..fx1, fy0..fy1)이 홀 사각형에 오도록 역산.
|
||||||
|
const drawing = useMemo(() => {
|
||||||
|
if (!floorRegion) {
|
||||||
|
return { x: 0, y: 0, w: hw, h: hh, clipped: false };
|
||||||
|
}
|
||||||
|
const [fx0, fy0, fx1, fy1] = floorRegion;
|
||||||
|
const rw = fx1 - fx0;
|
||||||
|
const rh = fy1 - fy0;
|
||||||
|
if (rw <= 0 || rh <= 0) {
|
||||||
|
return { x: 0, y: 0, w: hw, h: hh, clipped: false };
|
||||||
|
}
|
||||||
|
const w = hw / rw;
|
||||||
|
const h = hh / rh;
|
||||||
|
return { x: -fx0 * w, y: -fy0 * h, w, h, clipped: true };
|
||||||
|
}, [floorRegion, hw, hh]);
|
||||||
const [drawingOk, setDrawingOk] = useState(true);
|
const [drawingOk, setDrawingOk] = useState(true);
|
||||||
useEffect(() => setDrawingOk(true), [floorplanUrl]);
|
useEffect(() => setDrawingOk(true), [floorplanUrl]);
|
||||||
|
|
||||||
@ -164,10 +187,10 @@ export function FloorplanCanvas({
|
|||||||
{floorplanUrl && drawingOk && (
|
{floorplanUrl && drawingOk && (
|
||||||
<image
|
<image
|
||||||
href={floorplanUrl}
|
href={floorplanUrl}
|
||||||
x={0}
|
x={drawing.x}
|
||||||
y={0}
|
y={drawing.y}
|
||||||
width={hw}
|
width={drawing.w}
|
||||||
height={hh}
|
height={drawing.h}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
className="kx-canvas__drawing"
|
className="kx-canvas__drawing"
|
||||||
onError={() => setDrawingOk(false)}
|
onError={() => setDrawingOk(false)}
|
||||||
|
|||||||
@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
|
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
|
||||||
.kx-canvas__drawing {
|
.kx-canvas__drawing {
|
||||||
opacity: 0.3;
|
opacity: 0.55;
|
||||||
filter: saturate(0.35);
|
filter: saturate(0.55);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,41 @@
|
|||||||
/*
|
/*
|
||||||
* 홀 ID → 실측 홀 도면(크롤 JPG) URL 매핑.
|
* 홀 ID → 실측 홀 도면(크롤 JPG) URL + 전시 바닥면 캘리브레이션.
|
||||||
* 에셋은 docs/assets/floorplans 크롤본을 public/media/floorplans 로 빌드 내장(V46 포스터 패턴).
|
* 에셋은 docs/assets/floorplans 크롤본을 public/media/floorplans 로 빌드 내장(V46 포스터 패턴).
|
||||||
* 매핑 불가한 홀은 null — 캔버스는 기존 다크 서피스만 렌더(FloorplanCanvas onError 폴백과 동일).
|
* 매핑 불가한 홀은 null — 캔버스는 기존 다크 서피스만 렌더(FloorplanCanvas onError 폴백과 동일).
|
||||||
|
*
|
||||||
|
* FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이
|
||||||
|
* 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로
|
||||||
|
* 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14).
|
||||||
|
* 값은 색상 블롭 자동 측정(최대 연결 성분) 산출 — 도면 교체 시 재측정 필요.
|
||||||
*/
|
*/
|
||||||
const AVAILABLE_HALLS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
export type FloorRegion = [number, number, number, number];
|
||||||
|
|
||||||
|
const FLOOR_REGIONS: Record<string, FloorRegion> = {
|
||||||
|
H1: [0.1583, 0.4015, 0.9167, 0.7956],
|
||||||
|
H2: [0.0667, 0.3723, 0.8944, 0.8029],
|
||||||
|
H3: [0.0667, 0.3759, 0.8972, 0.8102],
|
||||||
|
H4: [0.0667, 0.3759, 0.8972, 0.8066],
|
||||||
|
H5: [0.0528, 0.4416, 0.9028, 0.8686],
|
||||||
|
H6: [0.3222, 0.2162, 0.6917, 0.8176],
|
||||||
|
H7: [0.2583, 0.2027, 0.7306, 0.8547],
|
||||||
|
H8: [0.275, 0.5034, 0.7194, 0.8446],
|
||||||
|
H9: [0.2833, 0.2061, 0.7111, 0.8581],
|
||||||
|
H10: [0.2778, 0.5068, 0.7194, 0.8581],
|
||||||
|
};
|
||||||
|
|
||||||
|
function hallKey(hallId: string | null | undefined): string | null {
|
||||||
|
const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
|
||||||
|
return m ? `H${Number(m[1])}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
|
export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
|
||||||
const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
|
const key = hallKey(hallId);
|
||||||
if (!m) return null;
|
if (!key || !(key in FLOOR_REGIONS)) return null;
|
||||||
const n = Number(m[1]);
|
return `/media/floorplans/hall${key.slice(1)}.jpg`;
|
||||||
return AVAILABLE_HALLS.has(n) ? `/media/floorplans/hall${n}.jpg` : null;
|
}
|
||||||
|
|
||||||
|
/** 도면 이미지 내 전시 바닥면 분율 영역 — 캔버스가 이 영역을 홀 사각형에 정렬한다. */
|
||||||
|
export function hallFloorRegion(hallId: string | null | undefined): FloorRegion | null {
|
||||||
|
const key = hallKey(hallId);
|
||||||
|
return key ? FLOOR_REGIONS[key] ?? null : null;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user