Compare commits
No commits in common. "0cc7f26c56558f950fa8ec8d0fd6c81ec653a06b" and "4fe3d18b7e397abacc258c13748c21926de3ed42" have entirely different histories.
0cc7f26c56
...
4fe3d18b7e
@ -57,32 +57,4 @@ public interface AdminDashboardMapper {
|
||||
LIMIT 20
|
||||
""")
|
||||
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,7 +1,6 @@
|
||||
package com.zioinfo.kintex.admin;
|
||||
|
||||
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
|
||||
import com.zioinfo.kintex.admin.dto.AdminStatsDto;
|
||||
import com.zioinfo.kintex.tenant.TenantContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@ -58,24 +57,6 @@ public class AdminDashboardService {
|
||||
return new AdminDashboardDto(kpis, visitorTrend, live, tenants);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 집계 지표(SCR-16 실전환) — 실 테이블 존재분만 반환. 테넌트 스코프는 대시보드와 동일(§8-2).
|
||||
* 실시간 방문객·주차·라이브 IoT 지표는 미포함(M10/M14 이후) — 화면에서 정직한 게이트 유지.
|
||||
*/
|
||||
public AdminStatsDto getStats(String tenant) {
|
||||
String tenantId = TenantContext.currentTenantId();
|
||||
Map<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) {
|
||||
if (o == null) return 0L;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
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,20 +37,17 @@ public class AuctionService {
|
||||
private final com.zioinfo.kintex.work.notification.NotificationService notificationService;
|
||||
private final com.zioinfo.kintex.mail.MailService mailService;
|
||||
private final com.zioinfo.kintex.mail.MailProperties mailProperties;
|
||||
private final AuctionRealtimeService realtime;
|
||||
|
||||
public AuctionService(AuctionMapper mapper,
|
||||
com.zioinfo.kintex.settlement.SettlementService settlementService,
|
||||
com.zioinfo.kintex.work.notification.NotificationService notificationService,
|
||||
com.zioinfo.kintex.mail.MailService mailService,
|
||||
com.zioinfo.kintex.mail.MailProperties mailProperties,
|
||||
AuctionRealtimeService realtime) {
|
||||
com.zioinfo.kintex.mail.MailProperties mailProperties) {
|
||||
this.mapper = mapper;
|
||||
this.settlementService = settlementService;
|
||||
this.notificationService = notificationService;
|
||||
this.mailService = mailService;
|
||||
this.mailProperties = mailProperties;
|
||||
this.realtime = realtime;
|
||||
}
|
||||
|
||||
// ── 목록(SCR-26) ──────────────────────────────────────────────────────────
|
||||
@ -131,25 +128,6 @@ public class AuctionService {
|
||||
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
|
||||
public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) {
|
||||
@ -217,8 +195,6 @@ public class AuctionService {
|
||||
|
||||
int myRank = mapper.lowerBidCount(id, total) + 1;
|
||||
Long lowest = lng(mapper.findAuction(id).get("lowestPrice"));
|
||||
// 순위 변동 → 커밋 후 공개 스냅샷 브로드캐스트(구독자 실시간 갱신 트리거).
|
||||
realtime.broadcastRanking(id, "BID");
|
||||
return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat,
|
||||
version, myRank, lowest);
|
||||
}
|
||||
@ -241,8 +217,6 @@ public class AuctionService {
|
||||
throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨
|
||||
}
|
||||
mapper.closeAuction(id);
|
||||
// 마감 → 봉인 상태(sealed=false) 변경을 구독자에게 반영.
|
||||
realtime.broadcastRanking(id, "CLOSE");
|
||||
return toSummary(mapper.findAuction(id));
|
||||
}
|
||||
|
||||
@ -288,8 +262,6 @@ public class AuctionService {
|
||||
// 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출.
|
||||
}
|
||||
|
||||
// 낙찰 확정 → 상태 전환을 구독자에게 반영(순위 화면 실시간 갱신).
|
||||
realtime.broadcastRanking(id, "AWARD");
|
||||
return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
|
||||
}
|
||||
|
||||
|
||||
@ -52,19 +52,6 @@ public final class AuctionDtos {
|
||||
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 게이팅. */
|
||||
public record AuctionDetail(
|
||||
String id, String eventId, String title, String category, String type, String status,
|
||||
|
||||
@ -128,35 +128,16 @@ public interface CmsMapper {
|
||||
""")
|
||||
Map<String, Object> findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo);
|
||||
|
||||
// ── 예약 게시(도달분 자동 게시 — 승인 상태만, @Scheduled 폴러/스윕이 사용) ─────
|
||||
/**
|
||||
* 예약 시각 도달·승인(approved) 상태인 예약 콘텐츠의 id/event 목록(스윕 상한).
|
||||
* ★raw '<' 미사용 — 부팅 크래시 회피 위해 {@code now() >= scheduled_at} 로 표현한다.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT id, event_id AS "eventId"
|
||||
FROM cms_content
|
||||
WHERE scheduled_at IS NOT NULL
|
||||
AND now() >= scheduled_at
|
||||
AND status = 'approved'
|
||||
ORDER BY scheduled_at
|
||||
LIMIT #{limit}
|
||||
""")
|
||||
List<Map<String, Object>> findDueScheduled(@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 예약 게시 단건 전이(멱등·이중 전이 방지) — approved→published 조건 UPDATE.
|
||||
* 반환=영향 행 수(0=이미 게시됨/전이 불가). WHERE 상태 조건으로 동시/재실행 시 이중 전이를 차단한다.
|
||||
*/
|
||||
// ── 예약 게시(도달분 자동 게시) ────────────────────────────────────────────
|
||||
/** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */
|
||||
@Update("""
|
||||
UPDATE cms_content
|
||||
SET status = 'published', published_at = now(), updated_at = now()
|
||||
WHERE id = #{id}
|
||||
AND status = 'approved'
|
||||
AND scheduled_at IS NOT NULL
|
||||
AND now() >= scheduled_at
|
||||
WHERE scheduled_at IS NOT NULL
|
||||
AND scheduled_at <= now()
|
||||
AND status <> 'published'
|
||||
""")
|
||||
int publishScheduledDue(@Param("id") String id);
|
||||
int publishDueScheduled();
|
||||
|
||||
// ── 미디어 라이브러리 ──────────────────────────────────────────────────────
|
||||
@Insert("""
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
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,7 +5,6 @@ import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.cms.dto.*;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
import com.zioinfo.kintex.common.audit.AuditLogService;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.common.text.HtmlSanitizer;
|
||||
@ -55,30 +54,23 @@ public class CmsService {
|
||||
private static final Map<String, String> LANG_NAMES = Map.of(
|
||||
"ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)");
|
||||
|
||||
/** 예약 게시 스윕 1회당 처리 상한(폭주 방지). */
|
||||
private static final int SCHED_SWEEP_LIMIT = 200;
|
||||
/** 예약 게시 감사/버전 행위자(시스템). */
|
||||
private static final String SCHED_ACTOR_ID = "system-scheduler";
|
||||
private static final String SCHED_ACTOR_NAME = "시스템(예약 게시)";
|
||||
|
||||
private final CmsMapper mapper;
|
||||
private final SystemAccessGuard scope;
|
||||
private final AiTextRouter aiRouter;
|
||||
private final AuditLogService audit;
|
||||
private final Path uploadRoot;
|
||||
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter, AuditLogService audit,
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter,
|
||||
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.mapper = mapper;
|
||||
this.scope = scope;
|
||||
this.aiRouter = aiRouter;
|
||||
this.audit = audit;
|
||||
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
// ── 콘텐츠 목록/조회 ───────────────────────────────────────────────────────
|
||||
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
|
||||
int page, int size) {
|
||||
runScheduledPublish();
|
||||
int p = Math.max(page, 0);
|
||||
int s = size <= 0 ? 50 : Math.min(size, 200);
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
@ -442,47 +434,20 @@ 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
|
||||
public int runScheduledPublish() {
|
||||
List<Map<String, Object>> due;
|
||||
try {
|
||||
due = mapper.findDueScheduled(SCHED_SWEEP_LIMIT);
|
||||
return mapper.publishDueScheduled();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("scheduled publish sweep skipped: {}", e.getClass().getSimpleName());
|
||||
log.warn("scheduled publish sweep skipped: {}", e.getMessage());
|
||||
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) {
|
||||
runScheduledPublish();
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
|
||||
q.put("eventId", blankToNull(eventId));
|
||||
|
||||
@ -353,12 +353,7 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율에 더해 요청 조건
|
||||
* (주출입구·무대·라운지 수)을 <b>예약 영역</b>으로 반영해 목표 수까지 배치한다.
|
||||
* 예약 영역(중앙 교차 통로·출입구 전면 클리어존·무대·라운지)과 겹치는 셀은 건너뛰어
|
||||
* 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
|
||||
*/
|
||||
/** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */
|
||||
private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
|
||||
double hallW, double hallD, AutoLayoutRequest req, char tag) {
|
||||
List<BoothDto> booths = new ArrayList<>();
|
||||
@ -369,14 +364,9 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
|
||||
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
|
||||
|
||||
List<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
|
||||
|
||||
int index = 0;
|
||||
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) {
|
||||
if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
|
||||
continue;
|
||||
}
|
||||
index++;
|
||||
boolean premium = index <= premiumTarget;
|
||||
String boothNo = String.format("%c-%03d", tag, index);
|
||||
@ -389,59 +379,6 @@ public class FloorplanServiceImpl implements FloorplanService {
|
||||
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) 허용. */
|
||||
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
|
||||
try {
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
-- 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,8 +38,7 @@ class CmsServiceTest {
|
||||
mapper = mock(CmsMapper.class);
|
||||
scope = mock(SystemAccessGuard.class);
|
||||
aiRouter = mock(AiTextRouter.class);
|
||||
service = new CmsService(mapper, scope, aiRouter,
|
||||
mock(com.zioinfo.kintex.common.audit.AuditLogService.class), "./build/test-uploads");
|
||||
service = new CmsService(mapper, scope, aiRouter, "./build/test-uploads");
|
||||
}
|
||||
|
||||
private Map<String, Object> contentRow(String status) {
|
||||
|
||||
@ -6,7 +6,6 @@ import { api } from './client';
|
||||
import type {
|
||||
AcceptInviteRequest,
|
||||
AdminDashboardData,
|
||||
AdminStats,
|
||||
AnalyticsData,
|
||||
AnalyticsPeriod,
|
||||
AnalyticsPerspective,
|
||||
@ -258,11 +257,6 @@ export const adminApi = {
|
||||
api.get<AdminDashboardData>(
|
||||
`/api/admin/dashboard${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
|
||||
),
|
||||
// SCR-16 실 집계 지표(사용자·행사·테넌트·등록업체·오늘 체크인) — 실 테이블 존재분만.
|
||||
stats: (tenant?: string) =>
|
||||
api.get<AdminStats>(
|
||||
`/api/admin/stats${tenant ? `?tenant=${encodeURIComponent(tenant)}` : ''}`,
|
||||
),
|
||||
};
|
||||
|
||||
// ── SCR-16 홀 현장 운영 ──
|
||||
|
||||
@ -163,14 +163,6 @@ export interface LayoutSummary {
|
||||
violationBlock: number;
|
||||
violationWarn: number;
|
||||
}
|
||||
/** 홀 마스터 정보(M2 LayoutDto.hall) — 캔버스 축척·경계 원천. dimsM=[폭, 깊이](m). */
|
||||
export interface HallInfoDto {
|
||||
hallId: string;
|
||||
label?: string | null;
|
||||
dimsM?: number[] | null;
|
||||
ceilingM?: number | null;
|
||||
targetBoothCount?: number | null;
|
||||
}
|
||||
export interface LayoutDto {
|
||||
layoutId: string;
|
||||
eventId: string;
|
||||
@ -179,7 +171,6 @@ export interface LayoutDto {
|
||||
name: string;
|
||||
status: string;
|
||||
booths: BoothDto[];
|
||||
hall?: HallInfoDto | null;
|
||||
summary: LayoutSummary;
|
||||
updatedAt: string;
|
||||
}
|
||||
@ -715,18 +706,6 @@ export interface AdminDashboardData {
|
||||
tenants: string[];
|
||||
}
|
||||
|
||||
// ── SCR-16 관리자 집계 지표 (admin.AdminStatsDto) — 실 테이블 존재분만 ──
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalEvents: number;
|
||||
ongoingEvents: number;
|
||||
upcomingEvents: number;
|
||||
tenantCount: number;
|
||||
companyCount: number;
|
||||
todayCheckins: number;
|
||||
}
|
||||
|
||||
// ── SCR-16 홀 현장 운영 (ops.OpsDto) ──
|
||||
export interface OpsHall {
|
||||
hallId: string;
|
||||
|
||||
@ -1,25 +1,14 @@
|
||||
/*
|
||||
* WebSocket(STOMP over SockJS) — 공용 실시간 구독 클라이언트.
|
||||
* 근거: 계약 §6 — 핸드셰이크 GET /ws(SockJS), 브로드캐스트 prefix /topic.
|
||||
* - RenderJob 완료: /topic/render/{jobId} (design.md §1-4)
|
||||
* - 옥션 실시간 순위: /topic/auctions/{auctionId}/ranking (M15 폴링→푸시 전환)
|
||||
* 단일 STOMP 연결을 refCount 로 공유하고, 연결/재연결/절단을 리스너로 브로드캐스트한다.
|
||||
* WebSocket(STOMP over SockJS) — RenderJob 완료 푸시 구독.
|
||||
* 근거: 계약 §6 — 핸드셰이크 GET /ws(SockJS), 브로드캐스트 prefix /topic,
|
||||
* 구독 /topic/render/{jobId} → RenderJobDto (design.md §1-4).
|
||||
*/
|
||||
import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
import type { RenderJobDto } from './types';
|
||||
|
||||
/** 구독자에게 알리는 연결 상태(옥션 순위 폴백 판단용). */
|
||||
export type WsStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
interface ConnListener {
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
let client: Client | null = null;
|
||||
let refCount = 0;
|
||||
const connListeners = new Set<ConnListener>();
|
||||
|
||||
const WS_URL = `${(import.meta.env.VITE_API_BASE as string | undefined) ?? ''}/ws`;
|
||||
|
||||
@ -31,30 +20,13 @@ function ensureClient(): Client {
|
||||
reconnectDelay: 4000,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
// 조용한 프로덕션 로깅
|
||||
debug: () => {},
|
||||
onConnect: () => connListeners.forEach((l) => l.onConnect()),
|
||||
onWebSocketClose: () => connListeners.forEach((l) => l.onDisconnect()),
|
||||
onStompError: () => connListeners.forEach((l) => l.onDisconnect()),
|
||||
});
|
||||
client.activate();
|
||||
return client;
|
||||
}
|
||||
|
||||
function acquire(): Client {
|
||||
refCount += 1;
|
||||
return ensureClient();
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
refCount -= 1;
|
||||
if (refCount <= 0 && client) {
|
||||
void client.deactivate();
|
||||
client = null;
|
||||
refCount = 0;
|
||||
connListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 RenderJob 완료/실패 이벤트를 구독한다.
|
||||
* @returns 해제 함수 — 언마운트 시 호출.
|
||||
@ -63,9 +35,11 @@ export function subscribeRenderJob(
|
||||
jobId: string,
|
||||
onUpdate: (job: RenderJobDto) => void,
|
||||
): () => void {
|
||||
const c = acquire();
|
||||
const topic = `/topic/render/${jobId}`;
|
||||
const c = ensureClient();
|
||||
refCount += 1;
|
||||
|
||||
let sub: StompSubscription | null = null;
|
||||
const topic = `/topic/render/${jobId}`;
|
||||
|
||||
const doSubscribe = () => {
|
||||
sub = c.subscribe(topic, (msg: IMessage) => {
|
||||
@ -77,74 +51,24 @@ export function subscribeRenderJob(
|
||||
});
|
||||
};
|
||||
|
||||
const listener: ConnListener = {
|
||||
onConnect: () => {
|
||||
sub?.unsubscribe();
|
||||
doSubscribe(); // 최초 연결·재연결 모두에서 재구독
|
||||
},
|
||||
onDisconnect: () => {
|
||||
sub = null;
|
||||
},
|
||||
};
|
||||
connListeners.add(listener);
|
||||
if (c.connected) doSubscribe();
|
||||
|
||||
return () => {
|
||||
connListeners.delete(listener);
|
||||
sub?.unsubscribe();
|
||||
release();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 옥션 실시간 순위 스냅샷을 구독한다(M15). 서버는 순위 변동(응찰·마감·낙찰) 시 커밋 후 푸시한다.
|
||||
* 페이로드는 봉인 안전한 공개 순위만 담으므로, 구독자는 이를 <b>재조회 트리거</b>로 쓰고
|
||||
* 개인화 순위는 인증 상세 API로 다시 받는 것을 권장한다.
|
||||
*
|
||||
* @param onStatus 연결 상태 콜백 — 'disconnected' 시 호출부가 폴링으로 폴백한다.
|
||||
* @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');
|
||||
// 연결 후 구독
|
||||
const prev = c.onConnect;
|
||||
c.onConnect = (frame) => {
|
||||
prev?.(frame);
|
||||
doSubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
connListeners.delete(listener);
|
||||
sub?.unsubscribe();
|
||||
release();
|
||||
refCount -= 1;
|
||||
if (refCount <= 0 && client) {
|
||||
void client.deactivate();
|
||||
client = null;
|
||||
refCount = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,16 +17,14 @@ import { IconClock, IconOperations } from '../../components/ui/icons';
|
||||
import { adminApi } from '../../api/endpoints';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { StatusPill } from '../work/workShared';
|
||||
import type { AdminDashboardData, AdminStats } from '../../api/types';
|
||||
import type { AdminDashboardData } from '../../api/types';
|
||||
import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin';
|
||||
import './admin.css';
|
||||
|
||||
/*
|
||||
* SCR-14/16 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식.
|
||||
* KPI: GET /api/admin/stats (실 집계 — 사용자·행사·테넌트·등록업체·오늘 체크인, 실 테이블 존재분만).
|
||||
* 라이브/추이: GET /api/admin/dashboard?tenant (진행 행사 점유). 관람객 시간대 추이는 센서 부재 → 빈 상태 유지.
|
||||
* 실시간 방문객·주차·라이브 IoT 지표는 M10/M14 이후 — 가짜 수치 생성 금지(게이트 유지).
|
||||
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 샘플로 강등(degraded 배너 표기).
|
||||
* SCR-14 관리자 백오피스 대시보드 (M18 랜딩). Stitch admin_dashboard 이식.
|
||||
* 정상 경로: GET /api/admin/dashboard?tenant (실집계 — 진행/예정 행사·사용자·라이브 점유).
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태.
|
||||
*/
|
||||
export function AdminDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
@ -38,20 +36,9 @@ export function AdminDashboardPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const sq = useQuery({
|
||||
queryKey: ['admin-stats', tenant],
|
||||
queryFn: () => adminApi.stats(tenant),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const degraded = isDegradable(q.error);
|
||||
const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null);
|
||||
const hardError = q.isError && !degraded;
|
||||
|
||||
const statsDegraded = isDegradable(sq.error);
|
||||
const stats: AdminStats | null = sq.data ?? (statsDegraded ? STATS_FALLBACK : null);
|
||||
const statsHardError = sq.isError && !statsDegraded;
|
||||
|
||||
const tenants = data?.tenants?.length ? data.tenants : TENANTS;
|
||||
const activeTenant = tenant ?? tenants[0];
|
||||
|
||||
@ -88,39 +75,13 @@ export function AdminDashboardPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{(degraded || statsDegraded) && (
|
||||
{degraded && (
|
||||
<div className="kx-admin__degraded-bar">
|
||||
<span className="kx-bi__degraded">{t('admin.offlineSample')}</span>
|
||||
{t('admin.degradedMsg')}
|
||||
</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 />}
|
||||
|
||||
{hardError && (
|
||||
@ -129,6 +90,20 @@ export function AdminDashboardPage() {
|
||||
|
||||
{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">
|
||||
<section className="kx-card kx-admin__chart-card" aria-label={t('admin.visitorTrend')}>
|
||||
<div className="kx-card__head">
|
||||
@ -238,60 +213,6 @@ const FALLBACK: AdminDashboardData = {
|
||||
tenants: TENANTS,
|
||||
};
|
||||
|
||||
/** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */
|
||||
const STATS_FALLBACK: AdminStats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalEvents: 0,
|
||||
ongoingEvents: 0,
|
||||
upcomingEvents: 0,
|
||||
tenantCount: 0,
|
||||
companyCount: 0,
|
||||
todayCheckins: 0,
|
||||
};
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, 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 키. */
|
||||
const ADMIN_MODULES: [string, string][] = [
|
||||
['admin.modUsers', '/admin/users'],
|
||||
|
||||
@ -17,11 +17,9 @@ import {
|
||||
auctionApi,
|
||||
formatWon,
|
||||
type AuctionDetail,
|
||||
type AuctionRankingSnapshot,
|
||||
type MaterialKind,
|
||||
type RankRow,
|
||||
} from './auctionApi';
|
||||
import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket';
|
||||
import { errMessage, useToast } from './aucShared';
|
||||
import './auction.css';
|
||||
|
||||
@ -53,44 +51,22 @@ export function AuctionDetailPage() {
|
||||
const { auctionId = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { show, node: toast } = useToast();
|
||||
const [tab, setTab] = useState<MaterialKind>('layout');
|
||||
const [anon, setAnon] = useState(true);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [bidPrice, setBidPrice] = useState('');
|
||||
const [leadDays, setLeadDays] = useState('');
|
||||
// WebSocket 연결 상태 — 'connected' 면 실시간 푸시로 갱신하고 폴링을 저속(폴백)으로 늦춘다.
|
||||
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
|
||||
const wsLive = wsStatus === 'connected';
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['auction', auctionId],
|
||||
queryFn: () => auctionApi.detail(auctionId),
|
||||
enabled: !!auctionId,
|
||||
retry: false,
|
||||
// 실시간 푸시가 살아 있으면 폴링을 30초 안전망으로 늦추고, 끊기면 즉시 5초 폴백.
|
||||
refetchInterval: wsLive ? 30000 : 5000,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
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 일 때만 서버 규칙 기반 산출 조회.
|
||||
const boqQ = useQuery({
|
||||
queryKey: ['auction', auctionId, 'boq'],
|
||||
@ -251,19 +227,7 @@ export function AuctionDetailPage() {
|
||||
{/* 우 — 실시간 순위·응찰 */}
|
||||
<aside className="kx-rank" aria-label="실시간 순위">
|
||||
<div className="kx-rank__head">
|
||||
<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>
|
||||
<h2>실시간 순위</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="kx-toggle"
|
||||
|
||||
@ -559,26 +559,6 @@
|
||||
font-weight: var(--fw-bold);
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -81,23 +81,6 @@ export interface AuctionDetail {
|
||||
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 {
|
||||
id: string;
|
||||
round: number;
|
||||
|
||||
@ -48,15 +48,6 @@ function fmtDate(iso: string | null): string {
|
||||
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() {
|
||||
const [items, setItems] = useState<CmsContent[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@ -65,8 +56,6 @@ export function CmsWorkflowPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [signage, setSignage] = useState(true);
|
||||
const [mailing, setMailing] = useState(false);
|
||||
const [schedInput, setSchedInput] = useState('');
|
||||
const [schedSaving, setSchedSaving] = useState(false);
|
||||
|
||||
async function reload(keepSelection = true) {
|
||||
setLoading(true);
|
||||
@ -94,27 +83,6 @@ export function CmsWorkflowPage() {
|
||||
[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) {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
@ -263,30 +231,12 @@ export function CmsWorkflowPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="kx-cms-sublabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div className="kx-cms-sublabel" style={{ marginBottom: 8 }}>
|
||||
예약 게시
|
||||
{selected?.scheduledAt && selected.status !== 'published' && (
|
||||
<span className="kx-cms-pill kx-cms-pill--scheduled">예약됨</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="kx-cms-sched">
|
||||
<IconCalendar size={18} />
|
||||
<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) 상태이면 시각 도달 시 자동 게시됩니다.`
|
||||
: '시각을 지정하면, 승인 후 예약 시각에 자동 게시됩니다. 비우고 저장하면 예약이 해제됩니다.'}
|
||||
<span>{selected?.scheduledAt ? fmtDate(selected.scheduledAt) : '예약 없음'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -9,7 +9,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
|
||||
import { AutoLayoutDialog } from './AutoLayoutDialog';
|
||||
import { ValidationPanel } from './ValidationPanel';
|
||||
import { sampleLayout, emptyLayout } from './sampleLayout';
|
||||
import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan';
|
||||
import { hallFloorplanUrl } from './hallFloorplan';
|
||||
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
|
||||
import './editor.css';
|
||||
|
||||
@ -173,11 +173,7 @@ export function BoothLayoutEditorPage() {
|
||||
) : (
|
||||
layout && (
|
||||
<FloorplanCanvas
|
||||
hallDims={
|
||||
layout.hall?.dimsM && layout.hall.dimsM.length >= 2
|
||||
? [layout.hall.dimsM[0], layout.hall.dimsM[1]]
|
||||
: [126, 90]
|
||||
}
|
||||
hallDims={[126, 90]}
|
||||
booths={layout.booths}
|
||||
layers={layers}
|
||||
selectedBoothId={selectedBoothId}
|
||||
@ -185,7 +181,6 @@ export function BoothLayoutEditorPage() {
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
floorplanUrl={hallFloorplanUrl(hallId)}
|
||||
floorRegion={hallFloorRegion(hallId)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@ -29,12 +29,6 @@ interface FloorplanCanvasProps {
|
||||
zoom: number;
|
||||
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
|
||||
floorplanUrl?: string | null;
|
||||
/**
|
||||
* 도면 이미지 내 전시 바닥면 분율 영역 [x0,y0,x1,y1] (hallFloorplan.ts 캘리브레이션).
|
||||
* 지정 시 이 영역이 홀 사각형(0,0~hw,hh)에 정렬되도록 이미지를 확대 배치 —
|
||||
* 부스 좌표(홀 로컬 m)가 도면의 채색 바닥면 "안"에 놓인다. 미지정 시 전체 스트레치(구 동작).
|
||||
*/
|
||||
floorRegion?: [number, number, number, number] | null;
|
||||
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
@ -61,26 +55,9 @@ export function FloorplanCanvas({
|
||||
onSelectBooth,
|
||||
zoom,
|
||||
floorplanUrl,
|
||||
floorRegion,
|
||||
onZoomChange,
|
||||
}: FloorplanCanvasProps) {
|
||||
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);
|
||||
useEffect(() => setDrawingOk(true), [floorplanUrl]);
|
||||
|
||||
@ -187,10 +164,10 @@ export function FloorplanCanvas({
|
||||
{floorplanUrl && drawingOk && (
|
||||
<image
|
||||
href={floorplanUrl}
|
||||
x={drawing.x}
|
||||
y={drawing.y}
|
||||
width={drawing.w}
|
||||
height={drawing.h}
|
||||
x={0}
|
||||
y={0}
|
||||
width={hw}
|
||||
height={hh}
|
||||
preserveAspectRatio="none"
|
||||
className="kx-canvas__drawing"
|
||||
onError={() => setDrawingOk(false)}
|
||||
|
||||
@ -25,8 +25,8 @@
|
||||
|
||||
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
|
||||
.kx-canvas__drawing {
|
||||
opacity: 0.55;
|
||||
filter: saturate(0.55);
|
||||
opacity: 0.3;
|
||||
filter: saturate(0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@ -1,41 +1,13 @@
|
||||
/*
|
||||
* 홀 ID → 실측 홀 도면(크롤 JPG) URL + 전시 바닥면 캘리브레이션.
|
||||
* 홀 ID → 실측 홀 도면(크롤 JPG) URL 매핑.
|
||||
* 에셋은 docs/assets/floorplans 크롤본을 public/media/floorplans 로 빌드 내장(V46 포스터 패턴).
|
||||
* 매핑 불가한 홀은 null — 캔버스는 기존 다크 서피스만 렌더(FloorplanCanvas onError 폴백과 동일).
|
||||
*
|
||||
* FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이
|
||||
* 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로
|
||||
* 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14).
|
||||
* 값은 색상 블롭 자동 측정(최대 연결 성분) 산출 — 도면 교체 시 재측정 필요.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const AVAILABLE_HALLS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
|
||||
export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
|
||||
const key = hallKey(hallId);
|
||||
if (!key || !(key in FLOOR_REGIONS)) return null;
|
||||
return `/media/floorplans/hall${key.slice(1)}.jpg`;
|
||||
}
|
||||
|
||||
/** 도면 이미지 내 전시 바닥면 분율 영역 — 캔버스가 이 영역을 홀 사각형에 정렬한다. */
|
||||
export function hallFloorRegion(hallId: string | null | undefined): FloorRegion | null {
|
||||
const key = hallKey(hallId);
|
||||
return key ? FLOOR_REGIONS[key] ?? null : null;
|
||||
const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return AVAILABLE_HALLS.has(n) ? `/media/floorplans/hall${n}.jpg` : null;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user