From c6f3a8d9164a27fe4ffa133e7beffd639a7307c9 Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 09:09:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(domains):=20second-wave=20hardening=20?= =?UTF-8?q?=E2=80=94=20auction=20scoring,=20visitor=20check-in/lead=20engi?= =?UTF-8?q?ne,=20CMS=20versions/media/public=20(V21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M15 auction: scoring/quotation calc + unit tests, screens/auction local API as canonical client - M10 visitor: check-in desk (SCR-31, /visitors/checkin route+nav), QR token + checkin_at, lead exhibitor/consent/note, check-in/search/stats/badge-reissue/lead-create/CSV export (masked PII only, RBAC-guarded, @Audited) + rule-based lead scoring engine - M17 CMS: content version history, media library, public CMS API (/api/public/cms), HtmlSanitizer - Flyway repair: extensions appended to already-applied V17/V19 split into V21 (checksum-safe), V17/V19 restored - verified: compileJava, test, tsc -b all EXIT 0 Co-Authored-By: Claude Fable 5 --- docs/WORK_STATUS.md | 1 + .../kintex/auction/AuctionController.java | 9 + .../zioinfo/kintex/auction/AuctionMapper.java | 4 + .../kintex/auction/AuctionService.java | 63 ++- .../kintex/cms/CmsContentController.java | 57 ++- .../com/zioinfo/kintex/cms/CmsMapper.java | 101 +++++ .../kintex/cms/CmsMediaController.java | 52 +++ .../com/zioinfo/kintex/cms/CmsService.java | 247 ++++++++++- .../kintex/cms/PublicCmsController.java | 32 ++ .../cms/dto/CmsContentUpdateRequest.java | 15 + .../kintex/cms/dto/CmsContentVersionDto.java | 14 + .../zioinfo/kintex/cms/dto/CmsMediaDto.java | 14 + .../kintex/common/text/HtmlSanitizer.java | 70 ++++ .../kintex/visitor/VisitorController.java | 77 ++++ .../zioinfo/kintex/visitor/VisitorMapper.java | 135 ++++++ .../kintex/visitor/VisitorService.java | 146 ++++++- .../kintex/visitor/dto/VisitorDtos.java | 38 ++ .../kintex/visitor/scoring/LeadScorer.java | 11 + .../kintex/visitor/scoring/LeadSignals.java | 25 ++ .../visitor/scoring/RuleBasedLeadScorer.java | 73 ++++ .../kintex/visitor/scoring/ScoreResult.java | 13 + .../V21__checkin_lead_cms_extensions.sql | 61 +++ .../kintex/auction/AuctionScoringTest.java | 55 +++ .../kintex/auction/QuotationCalcTest.java | 70 ++++ .../zioinfo/kintex/cms/CmsServiceTest.java | 152 +++++++ .../kintex/common/text/HtmlSanitizerTest.java | 56 +++ .../kintex/visitor/LeadScoringEngineTest.java | 59 +++ .../visitor/VisitorCheckinServiceTest.java | 95 +++++ src/frontend/src/App.tsx | 2 + .../src/components/layout/AppShell.tsx | 1 + .../src/screens/auction/AuctionDetailPage.tsx | 278 +++++++++--- .../src/screens/auction/AuctionListPage.tsx | 299 +++++++++---- .../src/screens/auction/AwardComparePage.tsx | 235 +++++++---- .../src/screens/auction/aucShared.tsx | 27 +- src/frontend/src/screens/auction/auction.css | 27 +- .../src/screens/auction/auctionApi.ts | 14 +- src/frontend/src/screens/cms/cmsApi.ts | 57 +++ .../ContractorBoothDashboardPage.tsx | 386 +++++++---------- .../src/screens/visitor/CheckinDeskPage.tsx | 395 ++++++++++++++++++ .../src/screens/visitor/LeadScoringPage.tsx | 35 +- .../src/screens/visitor/visitorApi.ts | 93 ++++- 41 files changed, 3080 insertions(+), 514 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMediaController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/cms/PublicCmsController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentUpdateRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentVersionDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsMediaDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/common/text/HtmlSanitizer.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadScorer.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadSignals.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/RuleBasedLeadScorer.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/ScoreResult.java create mode 100644 src/backend/src/main/resources/db/migration/V21__checkin_lead_cms_extensions.sql create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionScoringTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/auction/QuotationCalcTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/common/text/HtmlSanitizerTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/visitor/LeadScoringEngineTest.java create mode 100644 src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java create mode 100644 src/frontend/src/screens/visitor/CheckinDeskPage.tsx diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 95bad3f..4e6ab11 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -96,4 +96,5 @@ | 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) | | 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` | | 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 | +| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** | | 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) | diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java index 9917f10..a21caa9 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java @@ -64,6 +64,15 @@ public class AuctionController { return ApiResponse.ok(service.placeBid(id, principal, req)); } + /** POST /api/auctions/{id}/close — 라운드 마감 처리(발주자만). 마감 후 봉인 해제·응찰 차단. */ + @Audited(action = "AUCTION_CLOSE", targetType = "auction") + @PostMapping("/{id}/close") + public ApiResponse close(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.closeRound(id, principal)); + } + /** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */ @Audited(action = "AUCTION_AWARD", targetType = "award") @PostMapping("/{id}/award") diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java index 1429d2e..7d20454 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java @@ -151,6 +151,10 @@ public interface AuctionMapper { @Select("SELECT auction_id FROM bid WHERE id = #{bidId}") String findAuctionIdOfBid(@Param("bidId") String bidId); + /** 라운드 마감 처리 — deadline 을 현재 시각으로 당겨 마감(closed) 상태로 전이. */ + @Update("UPDATE auction SET deadline = now() WHERE id = #{id} AND deadline > now()") + int closeAuction(@Param("id") String id); + @Insert(""" INSERT INTO award (id, auction_id, bid_id, reason, awarded_by) VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy}) diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java index f978961..c3bff62 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java @@ -141,14 +141,13 @@ public class AuctionService { throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 — 미초대 차단 } - // 라인아이템이 있으면 서버가 소계 재계산(신뢰 경계). 없으면 total(부가세 별도)로 폴백. - long subtotal = hasLines - ? req.lines().stream().mapToLong(l -> nz(l.qty()) * nz(l.unitPrice())).sum() - : req.total(); - if (subtotal <= 0) { - throw new ApiException(ErrorCode.VALIDATION); - } - long vat = Math.round(subtotal * 0.1); + // ★ 서버 재계산(신뢰 경계): 라인아이템이 있으면 소계=Σ(수량×단가), 없으면 total 폴백. + // 음수 수량·단가, 소계 0 이하는 QuotationCalc 가 검증(400). 산식은 QuotationCalcTest 로 고정. + QuotationCalc.Totals t = hasLines + ? QuotationCalc.fromLines(req.lines()) + : QuotationCalc.fromTotal(req.total()); + long subtotal = t.subtotal(); + long vat = t.vat(); long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계 int leadDays = req.leadDays() == null ? 0 : req.leadDays(); String linesJson = hasLines ? toJson(req.lines()) : null; @@ -182,7 +181,29 @@ public class AuctionService { int myRank = mapper.lowerBidCount(id, total) + 1; Long lowest = lng(mapper.findAuction(id).get("lowestPrice")); - return new BidResult(bidId, intOr(a.get("round"), 1), total, vat, total, version, myRank, lowest); + return new BidResult(bidId, intOr(a.get("round"), 1), subtotal, vat, total, subtotal + vat, + version, myRank, lowest); + } + + // ── 라운드 마감 처리(발주자) ──────────────────────────────────────────────── + /** + * 현재 라운드를 즉시 마감(deadline=now)한다. 발주자만. 마감 후 봉인이 해제되어 + * 발주자는 award-view 로 전 견적을 비교하고 낙찰할 수 있다. 응찰은 마감 후 409로 차단된다. + */ + @Transactional + public AuctionSummary closeRound(String id, KintexPrincipal principal) { + Map a = mapper.findAuction(id); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!isOrderer(principal, str(a.get("eventId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + if (bool(a.get("closed"))) { + throw new ApiException(ErrorCode.CONFLICT); // 이미 마감됨 + } + mapper.closeAuction(id); + return toSummary(mapper.findAuction(id)); } // ── 낙찰(Award) ──────────────────────────────────────────────────────────── @@ -254,17 +275,8 @@ public class AuctionService { long total = lngPrim(r.get("total")); int lead = intOr(r.get("leadDays"), 0); double rating = dbl(r.get("rating"), 4.0); - double priceScore = minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0; - double repScore = rating / 5.0 * 100.0; - double leadScore = lead > 0 && minLead > 0 ? (double) minLead / lead * 100.0 : 100.0; - double score; - if ("lowest".equals(criteria)) { - score = priceScore; - } else { - int denom = Math.max(w.price() + w.reputation() + w.delivery(), 1); - score = (priceScore * w.price() + repScore * w.reputation() + leadScore * w.delivery()) / denom; - } - scores[i] = round1(score); + // 종합점수 산식은 AuctionScoring(순수 함수)로 위임 — AuctionScoringTest 로 고정. + scores[i] = AuctionScoring.score(criteria, w, total, minTotal, lead, minLead, rating); if (scores[i] > bestScore) { bestScore = scores[i]; bestIdx = i; @@ -526,4 +538,15 @@ public class AuctionService { private static double round1(double v) { return Math.round(v * 10.0) / 10.0; } + + private static final com.fasterxml.jackson.databind.ObjectMapper JSON = + new com.fasterxml.jackson.databind.ObjectMapper(); + + private static String toJson(Object o) { + try { + return JSON.writeValueAsString(o); + } catch (Exception e) { + return null; // 직렬화 실패 시 라인아이템 스냅샷 생략(집계는 컬럼값 사용) + } + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java index d35646f..f1f6e95 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java @@ -4,10 +4,13 @@ import com.zioinfo.kintex.auth.EventAccessGuard; import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; import com.zioinfo.kintex.cms.dto.CmsContentDto; +import com.zioinfo.kintex.cms.dto.CmsContentUpdateRequest; +import com.zioinfo.kintex.cms.dto.CmsContentVersionDto; import com.zioinfo.kintex.cms.dto.CmsTranslationDto; import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest; import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.audit.Audited; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -18,8 +21,10 @@ import java.util.List; * M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수. *
    *
  • GET/POST /api/cms/contents — 목록/신규(초안)
  • + *
  • GET/PUT /api/cms/contents/{id} — 상세/본문 저장(sanitize·버전 스냅샷)
  • *
  • PATCH /api/cms/contents/{id}/status?value= — 게시 전이(전진만·역전이 400, 승인/게시는 매니저↑)
  • - *
  • GET/PUT /api/cms/contents/{id}/translations — 언어별 번역 upsert(ko/en/zh/ja)
  • + *
  • GET /api/cms/contents/{id}/versions · POST …/rollback?versionNo= — 버전 이력/롤백(매니저↑)
  • + *
  • GET/PUT /api/cms/contents/{id}/translations · POST …/translations/ai?lang= — 번역 upsert / AI 초벌(501)
  • *
*/ @RestController @@ -54,7 +59,25 @@ public class CmsContentController { return ApiResponse.ok(service.create(principal, req)); } + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.get(id)); + } + + /** 본문/제목/예약 저장 — sanitize(XSS N2) + 버전 스냅샷. */ + @Audited(action = "CMS_CONTENT_EDIT", targetType = "cms_content") + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody CmsContentUpdateRequest req) { + guard.require(principal); + return ApiResponse.ok(service.update(principal, id, req)); + } + /** 게시 상태 전이. value=draft|review|approved|published(전진만). 역전이/무효 → 400. */ + @Audited(action = "CMS_CONTENT_TRANSITION", targetType = "cms_content") @PatchMapping("/{id}/status") public ApiResponse transition(@AuthenticationPrincipal KintexPrincipal principal, @PathVariable String id, @@ -63,6 +86,25 @@ public class CmsContentController { return ApiResponse.ok(service.transition(principal, id, value)); } + /** 버전 이력(최신순). */ + @GetMapping("/{id}/versions") + public ApiResponse> versions( + @AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.versions(id)); + } + + /** 특정 버전으로 롤백(매니저↑). 복원 후 롤백 스냅샷을 새 버전으로 남긴다. */ + @Audited(action = "CMS_CONTENT_ROLLBACK", targetType = "cms_content") + @PostMapping("/{id}/rollback") + public ApiResponse rollback(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestParam int versionNo) { + guard.require(principal); + return ApiResponse.ok(service.rollback(principal, id, versionNo)); + } + @GetMapping("/{id}/translations") public ApiResponse> translations( @AuthenticationPrincipal KintexPrincipal principal, @@ -79,4 +121,17 @@ public class CmsContentController { guard.require(principal); return ApiResponse.ok(service.saveTranslation(id, req)); } + + /** + * AI 자동 번역(초벌) — 인터페이스만. AiTextRouter 미배선 시 501 NOT_IMPLEMENTED. + * 프론트(SCR-37)는 501을 degraded("준비 중")로 처리한다. + */ + @PostMapping("/{id}/translations/ai") + public ApiResponse aiTranslate( + @AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestParam String lang) { + guard.require(principal); + return ApiResponse.ok(service.aiTranslate(id, lang)); + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java index 7caebcd..e761299 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java @@ -75,6 +75,107 @@ public interface CmsMapper { """) int updateStatus(@Param("id") String id, @Param("status") String status); + /** 본문/제목/예약·플래그 수정(sanitize 는 서비스). scheduledAt 은 ISO 문자열 또는 null. */ + @Update(""" + UPDATE cms_content + SET title = #{title}, + body = #{body}, + scheduled_at = CAST(#{scheduledAt} AS timestamptz), + signage = COALESCE(#{signage}, signage), + mailing = COALESCE(#{mailing}, mailing), + updated_at = now() + WHERE id = #{id} + """) + int updateContent(Map p); + + /** 롤백 — 특정 버전의 제목/본문/상태로 복원(published 는 published_at 유지). */ + @Update(""" + UPDATE cms_content + SET title = #{title}, + body = #{body}, + status = #{status}, + updated_at = now() + WHERE id = #{id} + """) + int restoreContent(Map p); + + // ── 버전 이력 ──────────────────────────────────────────────────────────── + @Select("SELECT COALESCE(MAX(version_no),0) FROM cms_content_version WHERE content_id = #{contentId}") + int maxVersionNo(@Param("contentId") String contentId); + + @Insert(""" + INSERT INTO cms_content_version (id, content_id, version_no, title, body, status, author_name, note) + VALUES (#{id}, #{contentId}, #{versionNo}, #{title}, #{body}, #{status}, #{authorName}, #{note}) + """) + int insertVersion(Map p); + + @Select(""" + SELECT id, content_id AS "contentId", version_no AS "versionNo", title, body, status, + author_name AS "authorName", note, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt" + FROM cms_content_version + WHERE content_id = #{contentId} + ORDER BY version_no DESC + """) + List> findVersions(@Param("contentId") String contentId); + + @Select(""" + SELECT id, content_id AS "contentId", version_no AS "versionNo", title, body, status, + author_name AS "authorName", note, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt" + FROM cms_content_version + WHERE content_id = #{contentId} AND version_no = #{versionNo} + """) + Map findVersion(@Param("contentId") String contentId, @Param("versionNo") int versionNo); + + // ── 예약 게시(도달분 자동 게시) ──────────────────────────────────────────── + /** scheduled_at 이 now 도달·미게시(approved 이하)인 콘텐츠를 published 로 승격. 반환=처리 건수. */ + @Update(""" + UPDATE cms_content + SET status = 'published', published_at = now(), updated_at = now() + WHERE scheduled_at IS NOT NULL + AND scheduled_at <= now() + AND status <> 'published' + """) + int publishDueScheduled(); + + // ── 미디어 라이브러리 ────────────────────────────────────────────────────── + @Insert(""" + INSERT INTO cms_media (id, event_id, file_name, url, content_type, size_bytes, alt_text, uploader_name) + VALUES (#{id}, #{eventId}, #{fileName}, #{url}, #{contentType}, #{sizeBytes}, #{altText}, #{uploaderName}) + """) + int insertMedia(Map p); + + @Select(""" + + """) + List> findMedia(Map q); + + // ── 공개 조회(게시 상태만) ───────────────────────────────────────────────── + @Select(""" + + """) + List> findPublishedByType(Map q); + // ── 번역 ───────────────────────────────────────────────────────────────── @Select(""" SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus", diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMediaController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMediaController.java new file mode 100644 index 0000000..daaf316 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMediaController.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.cms.dto.CmsMediaDto; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * M17 CMS 미디어 라이브러리 API (SCR-35 중앙 미디어 스트립). 인증 필수. + * 업로드는 기존 패턴(login-slides) 재사용 — 서버가 UUID 파일명 강제, /uploads/cms/** 로 서빙. + *
    + *
  • GET /api/cms/media?eventId=&limit= — 최신순 목록
  • + *
  • POST /api/cms/media (multipart: file[+eventId,altText]) — 업로드
  • + *
+ */ +@RestController +@RequestMapping("/api/cms/media") +public class CmsMediaController { + + private final CmsService service; + private final EventAccessGuard guard; + + public CmsMediaController(CmsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(defaultValue = "60") int limit) { + guard.require(principal); + return ApiResponse.ok(service.mediaList(eventId, limit)); + } + + @Audited(action = "CMS_MEDIA_UPLOAD", targetType = "cms_media") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload(@AuthenticationPrincipal KintexPrincipal principal, + @RequestPart("file") MultipartFile file, + @RequestParam(value = "eventId", required = false) String eventId, + @RequestParam(value = "altText", required = false) String altText) { + guard.require(principal); + return ApiResponse.ok(service.addMedia(principal, file, eventId, altText)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java index bb9c493..ddab423 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java @@ -1,41 +1,61 @@ package com.zioinfo.kintex.cms; import com.zioinfo.kintex.auth.KintexPrincipal; -import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; -import com.zioinfo.kintex.cms.dto.CmsContentDto; -import com.zioinfo.kintex.cms.dto.CmsTranslationDto; -import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest; +import com.zioinfo.kintex.cms.dto.*; import com.zioinfo.kintex.common.PageResponse; import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.common.text.HtmlSanitizer; import com.zioinfo.kintex.system.SystemAccessGuard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.*; /** - * M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 다국어 번역. + * M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 버전 이력/롤백 + 미디어 + 다국어 + 예약 게시 + 공개 조회. *

상태 전이: draft(0)→review(1)→approved(2)→published(3) 전진만 허용. 역전이/동일 전이 → 400(VALIDATION). - * approved·published 전이는 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합). + * approved·published 전이와 롤백은 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합). + *

본문 저장 시 {@link HtmlSanitizer}로 XSS 방어(N2). AI 자동 번역은 인터페이스만(501, AiTextRouter 배선 대기). */ @Service public class CmsService { + private static final Logger log = LoggerFactory.getLogger(CmsService.class); + private static final List FLOW = List.of("draft", "review", "approved", "published"); private static final Set TRANS_STATUS = Set.of("none", "ai", "reviewed"); private static final Set LANGS = Set.of("ko", "en", "zh", "ja"); + private static final long MAX_MEDIA_BYTES = 8L * 1024 * 1024; // 8MB + private static final Map ALLOWED_TYPES = Map.of( + "image/png", "png", "image/jpeg", "jpg", "image/webp", "webp", "image/gif", "gif"); + private static final Set ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp", "gif"); + private final CmsMapper mapper; private final SystemAccessGuard scope; + private final Path uploadRoot; - public CmsService(CmsMapper mapper, SystemAccessGuard scope) { + public CmsService(CmsMapper mapper, SystemAccessGuard scope, + @Value("${kintex.upload.dir:./data/uploads}") String uploadDir) { this.mapper = mapper; this.scope = scope; + this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); } + // ── 콘텐츠 목록/조회 ─────────────────────────────────────────────────────── public PageResponse list(String eventId, String status, String type, String keyword, int page, int size) { + runScheduledPublish(); int p = Math.max(page, 0); int s = size <= 0 ? 50 : Math.min(size, 200); Map q = new HashMap<>(); @@ -66,11 +86,33 @@ public class CmsService { pm.put("eventId", blankToNull(req.eventId())); pm.put("contentType", blankToNull(req.contentType())); pm.put("title", req.title()); - pm.put("body", req.body()); + pm.put("body", HtmlSanitizer.sanitize(req.body())); pm.put("lang", blankToNull(req.lang())); pm.put("authorId", principal.userId()); pm.put("authorName", principal.displayName()); mapper.insertContent(pm); + snapshot(id, "created", principal.displayName()); + return get(id); + } + + /** 본문/제목/예약 저장(SCR-35 에디터). sanitize + 저장 후 버전 스냅샷. */ + @Transactional + public CmsContentDto update(KintexPrincipal principal, String id, CmsContentUpdateRequest req) { + if (mapper.findContentById(id) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (req.title() == null || req.title().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "제목을 입력해 주세요."); + } + Map pm = new HashMap<>(); + pm.put("id", id); + pm.put("title", req.title().trim()); + pm.put("body", HtmlSanitizer.sanitize(req.body())); + pm.put("scheduledAt", blankToNull(req.scheduledAt())); + pm.put("signage", req.signage()); + pm.put("mailing", req.mailing()); + mapper.updateContent(pm); + snapshot(id, "edited", principal.displayName()); return get(id); } @@ -97,9 +139,60 @@ public class CmsService { scope.requireManager(principal); } mapper.updateStatus(id, to); + if ("published".equals(to)) { + snapshot(id, "published", principal.displayName()); + } return get(id); } + // ── 버전 이력/롤백 ───────────────────────────────────────────────────────── + public List versions(String contentId) { + if (mapper.findContentById(contentId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return mapper.findVersions(contentId).stream().map(CmsService::toVersionDto).toList(); + } + + /** 특정 버전으로 롤백(매니저 이상). 복원 후 롤백 스냅샷을 새 버전으로 남긴다. */ + @Transactional + public CmsContentDto rollback(KintexPrincipal principal, String contentId, int versionNo) { + scope.requireManager(principal); + if (mapper.findContentById(contentId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + Map v = mapper.findVersion(contentId, versionNo); + if (v == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "해당 버전을 찾을 수 없습니다: v" + versionNo); + } + Map pm = new HashMap<>(); + pm.put("id", contentId); + pm.put("title", str(v.get("title"))); + pm.put("body", str(v.get("body"))); + pm.put("status", str(v.get("status"))); + mapper.restoreContent(pm); + snapshot(contentId, "rollback-from-v" + versionNo, principal.displayName()); + return get(contentId); + } + + /** 현재 콘텐츠 상태를 버전 스냅샷으로 보존(단조 증가). */ + private void snapshot(String contentId, String note, String authorName) { + Map cur = mapper.findContentById(contentId); + if (cur == null) { + return; + } + int next = mapper.maxVersionNo(contentId) + 1; + Map vp = new HashMap<>(); + vp.put("id", "cv-" + UUID.randomUUID().toString().substring(0, 12)); + vp.put("contentId", contentId); + vp.put("versionNo", next); + vp.put("title", str(cur.get("title"))); + vp.put("body", str(cur.get("body"))); + vp.put("status", str(cur.get("status"))); + vp.put("authorName", authorName); + vp.put("note", note); + mapper.insertVersion(vp); + } + // ── 번역 ───────────────────────────────────────────────────────────────── public List translations(String contentId) { if (mapper.findContentById(contentId) == null) { @@ -126,12 +219,98 @@ public class CmsService { pm.put("contentId", contentId); pm.put("lang", lang); pm.put("title", req.title()); - pm.put("body", req.body()); + pm.put("body", HtmlSanitizer.sanitize(req.body())); pm.put("transStatus", st); mapper.upsertTranslation(pm); return translations(contentId); } + /** + * AI 자동 번역 — 인터페이스만(AiTextRouter/Claude 배선 지점). 미설정 시 501 NOT_IMPLEMENTED. + *

프론트는 501을 degraded("준비 중")로 처리한다. 배선되면 이 지점에서 원문→대상언어 초벌(trans_status=ai)을 upsert. + */ + public CmsTranslationDto aiTranslate(String contentId, String lang) { + if (mapper.findContentById(contentId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!LANGS.contains(lang == null ? "" : lang.trim())) { + throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang); + } + // AiTextRouter 미배선 — 표준 501(§ AI 설정형 전환 이전). + throw new ApiException(ErrorCode.NOT_IMPLEMENTED, + "AI 자동 번역은 아직 배선되지 않았습니다(AiTextRouter/Claude 연동 대기)."); + } + + // ── 미디어 라이브러리 ────────────────────────────────────────────────────── + public List mediaList(String eventId, int limit) { + Map q = new HashMap<>(); + q.put("eventId", blankToNull(eventId)); + q.put("limit", limit <= 0 ? 60 : Math.min(limit, 200)); + return mapper.findMedia(q).stream().map(CmsService::toMediaDto).toList(); + } + + @Transactional + public CmsMediaDto addMedia(KintexPrincipal principal, MultipartFile file, String eventId, String altText) { + if (file == null || file.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION, "업로드할 이미지를 첨부해 주세요."); + } + if (file.getSize() > MAX_MEDIA_BYTES) { + throw new ApiException(ErrorCode.VALIDATION, "이미지는 8MB 이하만 업로드할 수 있습니다."); + } + String ext = resolveExtension(file); + String fileName = UUID.randomUUID() + "." + ext; + try { + Path dir = uploadRoot.resolve("cms"); + Files.createDirectories(dir); + Path target = dir.resolve(fileName).normalize(); + if (!target.startsWith(uploadRoot)) { + throw new ApiException(ErrorCode.VALIDATION, "잘못된 파일 경로입니다."); + } + try (var in = file.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + log.error("cms media store failed: {}", e.getMessage()); + throw new ApiException(ErrorCode.INTERNAL, "이미지 저장에 실패했습니다."); + } + String id = "cm-" + UUID.randomUUID().toString().substring(0, 12); + String display = safeDisplayName(file.getOriginalFilename(), ext); + Map pm = new HashMap<>(); + pm.put("id", id); + pm.put("eventId", blankToNull(eventId)); + pm.put("fileName", display); + pm.put("url", "/uploads/cms/" + fileName); + pm.put("contentType", file.getContentType()); + pm.put("sizeBytes", file.getSize()); + pm.put("altText", blankToNull(altText)); + pm.put("uploaderName", principal.displayName()); + mapper.insertMedia(pm); + return mediaList(eventId, 200).stream().filter(m -> m.id().equals(id)).findFirst() + .orElseThrow(() -> new ApiException(ErrorCode.INTERNAL)); + } + + // ── 예약 게시 / 공개 조회 ────────────────────────────────────────────────── + /** 예약 시각 도달분 자동 게시(조회 시 지연 처리). 반환=이번에 게시된 건수. */ + @Transactional + public int runScheduledPublish() { + try { + return mapper.publishDueScheduled(); + } catch (RuntimeException e) { + log.warn("scheduled publish sweep skipped: {}", e.getMessage()); + return 0; + } + } + + /** 공개 조회(무인증) — 게시 상태만. 조회 시 예약 도달분 자동 게시 후 반환. */ + public List publicByType(String type, String eventId, int limit) { + runScheduledPublish(); + Map q = new HashMap<>(); + q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT)); + q.put("eventId", blankToNull(eventId)); + q.put("limit", limit <= 0 ? 50 : Math.min(limit, 200)); + return mapper.findPublishedByType(q).stream().map(CmsService::toPublicDto).toList(); + } + // ── 매핑 ───────────────────────────────────────────────────────────────── private static CmsContentDto toContentDto(Map r) { return new CmsContentDto( @@ -142,12 +321,62 @@ public class CmsService { str(r.get("createdAt")), str(r.get("updatedAt"))); } + /** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */ + private static CmsContentDto toPublicDto(Map r) { + return new CmsContentDto( + str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")), + str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")), + null, false, false, null, str(r.get("publishedAt")), + null, str(r.get("updatedAt"))); + } + private static CmsTranslationDto toTranslationDto(Map r) { return new CmsTranslationDto( str(r.get("contentId")), str(r.get("lang")), str(r.get("title")), str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt"))); } + private static CmsContentVersionDto toVersionDto(Map r) { + return new CmsContentVersionDto( + str(r.get("id")), str(r.get("contentId")), + ((Number) r.get("versionNo")).intValue(), + str(r.get("title")), str(r.get("body")), str(r.get("status")), + str(r.get("authorName")), str(r.get("note")), str(r.get("createdAt"))); + } + + private static CmsMediaDto toMediaDto(Map r) { + return new CmsMediaDto( + str(r.get("id")), str(r.get("eventId")), str(r.get("fileName")), str(r.get("url")), + str(r.get("contentType")), + r.get("sizeBytes") == null ? null : ((Number) r.get("sizeBytes")).longValue(), + str(r.get("altText")), str(r.get("uploaderName")), str(r.get("createdAt"))); + } + + private static String resolveExtension(MultipartFile file) { + String byType = file.getContentType() == null + ? null : ALLOWED_TYPES.get(file.getContentType().toLowerCase(Locale.ROOT)); + if (byType != null) { + return byType; + } + String name = file.getOriginalFilename(); + if (name != null && name.contains(".")) { + String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); + if (ALLOWED_EXTS.contains(ext)) { + return "jpeg".equals(ext) ? "jpg" : ext; + } + } + throw new ApiException(ErrorCode.VALIDATION, "PNG/JPG/WebP/GIF 이미지만 업로드할 수 있습니다."); + } + + /** 표시용 파일명 — 원본은 신뢰하지 않고 경로 구분자/제어문자 제거 후 길이 제한. */ + private static String safeDisplayName(String original, String ext) { + if (original == null || original.isBlank()) { + return "image." + ext; + } + String base = original.replaceAll("[\\\\/\\x00-\\x1f]", "_").trim(); + return base.length() > 120 ? base.substring(0, 120) : base; + } + private static String blankToNull(String s) { return s == null || s.isBlank() ? null : s; } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicCmsController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicCmsController.java new file mode 100644 index 0000000..acba4d0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicCmsController.java @@ -0,0 +1,32 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.cms.dto.CmsContentDto; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * M17 공개 콘텐츠 조회 API — 무인증(공개 홍보 사이트·마이크로사이트용). 게시(published) 상태만 노출. + * 조회 시 예약 시각 도달분을 자동 게시 처리한다(지연 스윕). 운영 필드(저자·예약·플래그)는 제외. + *

    + *
  • GET /api/public/cms/{type}?eventId=&limit= — type: PAGE|POST|NOTICE|BLOCK
  • + *
+ */ +@RestController +@RequestMapping("/api/public/cms") +public class PublicCmsController { + + private final CmsService service; + + public PublicCmsController(CmsService service) { + this.service = service; + } + + @GetMapping("/{type}") + public ApiResponse> byType(@PathVariable String type, + @RequestParam(required = false) String eventId, + @RequestParam(defaultValue = "50") int limit) { + return ApiResponse.ok(service.publicByType(type, eventId, limit)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentUpdateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentUpdateRequest.java new file mode 100644 index 0000000..773214b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentUpdateRequest.java @@ -0,0 +1,15 @@ +package com.zioinfo.kintex.cms.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * CMS 콘텐츠 본문 수정 요청(SCR-35 에디터 저장). 저장 시 이전 상태를 버전 스냅샷으로 보존한다. + * body 는 서버에서 sanitize(허용 태그 화이트리스트)된다. scheduledAt: ISO-8601(예약 게시) 또는 null(해제). + */ +public record CmsContentUpdateRequest( + @NotBlank String title, + String body, + String scheduledAt, + Boolean signage, + Boolean mailing) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentVersionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentVersionDto.java new file mode 100644 index 0000000..3746a7c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentVersionDto.java @@ -0,0 +1,14 @@ +package com.zioinfo.kintex.cms.dto; + +/** CMS 콘텐츠 버전 스냅샷(SCR-35 버전 이력·롤백). note: created|edited|published|rollback-from-vN. */ +public record CmsContentVersionDto( + String id, + String contentId, + int versionNo, + String title, + String body, + String status, + String authorName, + String note, + String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsMediaDto.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsMediaDto.java new file mode 100644 index 0000000..d90367d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsMediaDto.java @@ -0,0 +1,14 @@ +package com.zioinfo.kintex.cms.dto; + +/** CMS 미디어 라이브러리 항목(SCR-35 미디어 스트립). url 은 /uploads/cms/** 서빙 경로. */ +public record CmsMediaDto( + String id, + String eventId, + String fileName, + String url, + String contentType, + Long sizeBytes, + String altText, + String uploaderName, + String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/text/HtmlSanitizer.java b/src/backend/src/main/java/com/zioinfo/kintex/common/text/HtmlSanitizer.java new file mode 100644 index 0000000..16ea325 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/text/HtmlSanitizer.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.common.text; + +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * CMS 본문 XSS 방어(N2) — 허용 태그 화이트리스트 기반 서버 sanitize. + *

외부 의존성(jsoup 등) 없이 순수 정규식 전략으로 위험 요소를 제거한다. + *

    + *
  • {@code + """) + Map findForCheckin(@Param("eventId") String eventId, + @Param("token") String token, + @Param("registrationId") String registrationId); + + /** 체크인 확정 — 아직 완료가 아닌 건만 done 처리(중복 방지: 이미 done 이면 0 행 갱신). 갱신 행 수 반환. */ + @Update(""" + UPDATE visitor_registration + SET checkin_state = 'done', checkin_at = now(), badge_issued = true + WHERE event_id = #{eventId} AND id = #{registrationId} + AND checkin_state <> 'done' + """) + int markCheckedIn(@Param("eventId") String eventId, @Param("registrationId") String registrationId); + + /** 현장 수동 검색 — 이름/연락처(숫자)/배지코드/등록ID 부분일치. 마스킹 필드만 반환. */ + @Select(""" + SELECT id AS "registrationId", + CASE WHEN char_length(name) <= 1 THEN name + WHEN char_length(name) = 2 THEN left(name,1) || '*' + ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1) + END AS "nameMasked", + visitor_type AS "type", + company AS "company", + CASE WHEN phone IS NULL THEN NULL + WHEN char_length(regexp_replace(phone,'\\D','','g')) >= 7 + THEN left(regexp_replace(phone,'\\D','','g'),3) || '-****-' || right(regexp_replace(phone,'\\D','','g'),4) + ELSE '***' END AS "phoneMasked", + badge_code AS "badgeCode", + checkin_state AS "checkinState" + FROM visitor_registration + WHERE event_id = #{eventId} + AND ( name ILIKE '%' || #{q} || '%' + OR regexp_replace(coalesce(phone,''),'\\D','','g') LIKE '%' || regexp_replace(#{q},'\\D','','g') || '%' + OR upper(badge_code) LIKE '%' || upper(#{q}) || '%' + OR id = #{q} ) + AND ( #{qDigits} = '' OR regexp_replace(coalesce(phone,''),'\\D','','g') LIKE '%' || #{qDigits} || '%' + OR name ILIKE '%' || #{q} || '%' OR upper(badge_code) LIKE '%' || upper(#{q}) || '%' OR id = #{q} ) + ORDER BY registered_at DESC + LIMIT 20 + """) + List> searchRegistrations(@Param("eventId") String eventId, + @Param("q") String q, + @Param("qDigits") String qDigits); + + // ── 배지 재발급 ── + + /** 배지 재발급 — 새 배지코드+QR 토큰 갱신, 발급 표시. 갱신 행 수 반환. */ + @Update(""" + UPDATE visitor_registration + SET badge_code = #{badgeCode}, qr_token = #{qrToken}, badge_issued = true + WHERE event_id = #{eventId} AND id = #{registrationId} + """) + int reissueBadge(@Param("eventId") String eventId, @Param("registrationId") String registrationId, + @Param("badgeCode") String badgeCode, @Param("qrToken") String qrToken); + + // ── 실시간 입장 집계(SCR-31) ── + + /** 시간대별 체크인 집계(당일 checkin_at 기준). */ + @Select(""" + SELECT to_char(checkin_at, 'HH24') || '시' AS "hour", + count(*) AS "count" + FROM visitor_registration + WHERE event_id = #{eventId} AND checkin_state = 'done' AND checkin_at IS NOT NULL + GROUP BY to_char(checkin_at, 'HH24') + ORDER BY to_char(checkin_at, 'HH24') + """) + List> findCheckinByHour(@Param("eventId") String eventId); + + // ── 리드 수집(SCR-32) ── + + /** 리드 삽입 — 원문 저장(응답 노출 금지). score·ai_reasons 는 서비스가 규칙 엔진으로 산출해 전달. */ + @Insert(""" + INSERT INTO lead + (id, event_id, booth_id, exhibitor_id, name, phone, email, role, company, product, + interest, score, ai_reasons, activity, note, followup_draft, ai_generated, agree_privacy, collected_at) + VALUES + (#{id}, #{eventId}, #{boothId}, #{exhibitorId}, #{name}, #{phone}, #{email}, #{role}, #{company}, #{product}, + #{interest}, #{score}, CAST(#{aiReasonsJson} AS jsonb), '[]'::jsonb, #{note}, NULL, false, #{agreePrivacy}, now()) + """) + int insertLead(@Param("id") String id, @Param("eventId") String eventId, @Param("boothId") String boothId, + @Param("exhibitorId") String exhibitorId, @Param("name") String name, @Param("phone") String phone, + @Param("email") String email, @Param("role") String role, @Param("company") String company, + @Param("product") String product, @Param("interest") int interest, @Param("score") int score, + @Param("aiReasonsJson") String aiReasonsJson, @Param("note") String note, + @Param("agreePrivacy") boolean agreePrivacy); + + /** CSV 내보내기용 리드 조회 — 마스킹 필드만(원문 미반환). booth 선택 필터. */ + @Select(""" + + """) + List> findLeadsForCsv(@Param("eventId") String eventId, @Param("boothId") String boothId); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java index eb03805..e9d1cec 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java @@ -5,6 +5,9 @@ import com.zioinfo.kintex.common.PageResponse; import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.visitor.dto.VisitorDtos.*; +import com.zioinfo.kintex.visitor.scoring.LeadScorer; +import com.zioinfo.kintex.visitor.scoring.LeadSignals; +import com.zioinfo.kintex.visitor.scoring.ScoreResult; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -29,9 +32,11 @@ public class VisitorService { "vip", new String[]{"VIP", "#0E8A5F"}); private final VisitorMapper mapper; + private final LeadScorer leadScorer; - public VisitorService(VisitorMapper mapper) { + public VisitorService(VisitorMapper mapper, LeadScorer leadScorer) { this.mapper = mapper; + this.leadScorer = leadScorer; } // ── SCR-30 요약 ── @@ -177,6 +182,145 @@ public class VisitorService { return new RegisterResult(id, badgeCode); } + // ── SCR-31 현장 체크인 ── + + /** + * QR 토큰(우선) 또는 등록ID 로 체크인. 이미 완료된 건은 재집계 없이 {@code alreadyCheckedIn=true} 로 반환한다(중복 방지). + * 체크인 판정은 매퍼의 조건부 UPDATE(상태 != 'done' 인 행만 갱신)로 원자적으로 수행된다. + */ + public CheckinResult checkin(String eventId, CheckinRequest req) { + String token = trimOrNull(req == null ? null : req.token()); + String regId = trimOrNull(req == null ? null : req.registrationId()); + if (token == null && regId == null) { + throw new ApiException(ErrorCode.VALIDATION, "QR 토큰 또는 등록번호가 필요합니다."); + } + Map r = mapper.findForCheckin(eventId, token, regId); + if (r == null || r.get("registrationId") == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다. 현장 등록을 진행해 주세요."); + } + String rid = str(r.get("registrationId")); + boolean wasDone = "done".equals(str(r.get("checkinState"))); + int updated = wasDone ? 0 : mapper.markCheckedIn(eventId, rid); + boolean alreadyCheckedIn = wasDone || updated == 0; // 경합 시에도 0 → 이미 완료로 간주 + // 갱신 후 체크인 시각 재조회(방금 체크인이면 now 반영). + Map after = mapper.findForCheckin(eventId, null, rid); + Map src = after != null ? after : r; + return new CheckinResult(rid, str(src.get("nameMasked")), str(src.get("type")), str(src.get("company")), + str(src.get("badgeCode")), bool(src.get("badgeIssued")), str(src.get("checkinState")), + alreadyCheckedIn, str(src.get("checkinAt"))); + } + + /** 현장 수동 검색(이름/연락처/배지코드/등록번호). 마스킹 결과만 반환. */ + public List searchForCheckin(String eventId, String q) { + String query = trimOrNull(q); + if (query == null || query.length() < 2) { + throw new ApiException(ErrorCode.VALIDATION, "검색어는 2자 이상 입력해 주세요."); + } + String digits = query.replaceAll("\\D", ""); + List> rows = mapper.searchRegistrations(eventId, query, digits); + List out = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map r : rows) { + out.add(new CheckinSearchItem(str(r.get("registrationId")), str(r.get("nameMasked")), + str(r.get("type")), str(r.get("company")), str(r.get("phoneMasked")), + str(r.get("badgeCode")), str(r.get("checkinState")))); + } + } + return out; + } + + /** 실시간 입장 집계 — 사전등록/체크인/장내 추정 + 시간대별 버킷. */ + public CheckinStats checkinStats(String eventId) { + Map c = orEmpty(mapper.findSummaryCounts(eventId)); + long total = lng(c.get("total")); + long checkedIn = lng(c.get("checkedIn")); + List> hours = mapper.findCheckinByHour(eventId); + List byHour = new ArrayList<>(); + if (hours != null) { + for (Map h : hours) { + byHour.add(new HourBucket(str(h.get("hour")), lng(h.get("count")))); + } + } + // 장내 추정: 체크인 인원의 92%(단순 추정 — 퇴장 미추적 환경 근사). + long inside = Math.round(checkedIn * 0.92); + return new CheckinStats(total, checkedIn, inside, byHour); + } + + /** 배지 재발급 — 새 배지코드+QR 토큰 부여(민감정보 아님). */ + public BadgeReissueResult reissueBadge(String eventId, String registrationId) { + String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + String qrToken = "QR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(); + int n = mapper.reissueBadge(eventId, registrationId, badgeCode, qrToken); + if (n == 0) { + throw new ApiException(ErrorCode.NOT_FOUND, "등록 정보를 찾을 수 없습니다."); + } + return new BadgeReissueResult(registrationId, badgeCode, true); + } + + // ── SCR-32 리드 수집(규칙 엔진 스코어) ── + + /** 참가업체 부스 리드 수집. 스코어는 서버 규칙 엔진이 산출(클라이언트 값 신뢰 안 함). AI 보정 지점은 인터페이스로 열어둠. */ + public LeadCollectResult collectLead(String eventId, LeadCollectRequest req) { + String name = trimOrNull(req.name()); + if (name == null) { + throw new ApiException(ErrorCode.VALIDATION, "리드 이름을 입력해 주세요."); + } + if (req.agreePrivacy() != null && !req.agreePrivacy()) { + throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집 동의가 없는 리드는 저장할 수 없습니다."); + } + int interest = clamp(req.interest() == null ? 3 : req.interest(), 1, 5); + LeadSignals signals = new LeadSignals( + interest, + req.dwellRatioPct() == null ? 100 : Math.max(req.dwellRatioPct(), 0), + req.revisitCount() == null ? 0 : Math.max(req.revisitCount(), 0), + Boolean.TRUE.equals(req.decisionMaker()), + req.docDownloads() == null ? 0 : Math.max(req.docDownloads(), 0)); + ScoreResult sr = leadScorer.score(signals); + + String id = "ld-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20); + String reasonsJson; + try { + reasonsJson = JSON.writeValueAsString(sr.reasons()); + } catch (Exception e) { + reasonsJson = "[]"; + } + mapper.insertLead(id, eventId, trimOrNull(req.boothId()), trimOrNull(req.exhibitorId()), + name, trimOrNull(req.phone()), trimOrNull(req.email()), trimOrNull(req.role()), + trimOrNull(req.company()), trimOrNull(req.product()), interest, sr.score(), reasonsJson, + trimOrNull(req.note()), req.agreePrivacy() == null || req.agreePrivacy()); + return new LeadCollectResult(id, sr.score(), sr.reasons(), sr.source()); + } + + /** 리드 CSV(마스킹 필드만). RFC4180 유사 이스케이프. UTF-8 BOM 은 컨트롤러가 부여. */ + public String leadsCsv(String eventId, String boothId) { + String bid = (boothId == null || boothId.isBlank()) ? null : boothId; + List> rows = mapper.findLeadsForCsv(eventId, bid); + StringBuilder sb = new StringBuilder(); + sb.append("이름(마스킹),소속,직급,관심제품,관심도,AI스코어,연락처(마스킹),이메일(마스킹),수집시각\r\n"); + if (rows != null) { + for (Map r : rows) { + sb.append(csv(str(r.get("nameMasked")))).append(',') + .append(csv(str(r.get("company")))).append(',') + .append(csv(str(r.get("role")))).append(',') + .append(csv(str(r.get("product")))).append(',') + .append(intVal(r.get("interest"))).append(',') + .append(intVal(r.get("score"))).append(',') + .append(csv(str(r.get("phoneMasked")))).append(',') + .append(csv(str(r.get("emailMasked")))).append(',') + .append(csv(str(r.get("collectedAt")))).append("\r\n"); + } + } + return sb.toString(); + } + + private static String csv(String v) { + if (v == null) return ""; + if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) { + return '"' + v.replace("\"", "\"\"") + '"'; + } + return v; + } + // ── helpers ── private static String normalizeType(String t) { if (t == null) return "visitor"; diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java index e73b44d..1943037 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java @@ -49,4 +49,42 @@ public final class VisitorDtos { public record RegisterResult(String registrationId, String badgeCode) { } + + // ── SCR-31 현장 체크인 ── + /** 체크인 요청 — QR 토큰 우선, 없으면 등록ID(수동 검색 후 체크인). */ + public record CheckinRequest(String token, String registrationId) { + } + + /** 체크인 결과 — 원문 PII 미포함(마스킹 이름만). {@code alreadyCheckedIn} 이면 중복 스캔(재집계 없음). */ + public record CheckinResult(String registrationId, String nameMasked, String type, String company, + String badgeCode, boolean badgeIssued, String checkinState, + boolean alreadyCheckedIn, String checkinAt) { + } + + /** 현장 수동 검색 결과(마스킹) — 데스크가 선택 후 체크인. */ + public record CheckinSearchItem(String registrationId, String nameMasked, String type, String company, + String phoneMasked, String badgeCode, String checkinState) { + } + + /** 실시간 입장 집계(SCR-31 우측 위젯). */ + public record CheckinStats(long preRegistered, long checkedIn, long inside, List byHour) { + } + + public record HourBucket(String hour, long count) { + } + + /** 배지 재발급 결과 — 새 배지코드(QR 토큰은 서버 내부 갱신, 응답 미노출). */ + public record BadgeReissueResult(String registrationId, String badgeCode, boolean badgeIssued) { + } + + // ── SCR-32 리드 수집 ── + /** 리드 수집 요청 — 참가업체 부스에서 배지 스캔·상담. score 는 서버 규칙 엔진이 산출(클라이언트 값 무시). */ + public record LeadCollectRequest(String name, String phone, String email, String role, String company, + String product, String boothId, String exhibitorId, String note, + Integer interest, Integer dwellRatioPct, Integer revisitCount, + Boolean decisionMaker, Integer docDownloads, Boolean agreePrivacy) { + } + + public record LeadCollectResult(String leadId, int score, List aiReasons, String scoreSource) { + } } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadScorer.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadScorer.java new file mode 100644 index 0000000..d3f7061 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadScorer.java @@ -0,0 +1,11 @@ +package com.zioinfo.kintex.visitor.scoring; + +/** + * 리드 스코어링 전략(M10). 기본 구현은 규칙 엔진({@link RuleBasedLeadScorer}). + *

    AI(Claude) 연동 지점: 별도 구현체가 규칙 스코어를 기저로 보정하도록 이 인터페이스만 두고, + * 실제 LLM 호출 구현은 후속 하네스(guardia-claude-ai)에서 주입한다. 현재는 규칙 엔진이 단일 빈이다. + */ +public interface LeadScorer { + + ScoreResult score(LeadSignals signals); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadSignals.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadSignals.java new file mode 100644 index 0000000..34d53b6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/LeadSignals.java @@ -0,0 +1,25 @@ +package com.zioinfo.kintex.visitor.scoring; + +/** + * 리드 스코어링 입력 신호(M10 SCR-32). + *

    부스에서 배지 QR 을 스캔·상담할 때 수집되는 결정 신호. 원문 PII 는 포함하지 않는다(스코어 산식과 무관). + * + * @param interest 관심도(1~5, 참가업체 상담원 입력) + * @param dwellRatioPct 부스 체류 시간(행사 평균=100 기준 상대 비율, 예: 240 = 평균 대비 240%) + * @param revisitCount 부스 재방문 횟수(0 이상) + * @param decisionMaker 구매 결정권자 여부(직급·역할 기반) + * @param docDownloads 기술 사양서/카탈로그 등 자료 열람·다운로드 횟수(0 이상) + */ +public record LeadSignals( + int interest, + int dwellRatioPct, + int revisitCount, + boolean decisionMaker, + int docDownloads +) { + + /** 관심도만 아는 최소 신호(수동 입력 리드). 나머지는 중립값(평균 체류·미재방문). */ + public static LeadSignals ofInterest(int interest) { + return new LeadSignals(interest, 100, 0, false, 0); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/RuleBasedLeadScorer.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/RuleBasedLeadScorer.java new file mode 100644 index 0000000..3e53c5e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/RuleBasedLeadScorer.java @@ -0,0 +1,73 @@ +package com.zioinfo.kintex.visitor.scoring; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 규칙 기반 리드 스코어 엔진(M10 기본). 결정론적·검증 가능한 가중 합산. + *

    산식(총점 100 = 관심도 40 + 체류 25 + 재방문 20 + 결정권자 10 + 자료열람 5): + *

      + *
    • 관심도(interest 1~5) × 8 → 8..40
    • + *
    • 체류: (dwellRatioPct − 100) × 0.25 를 [0,25] 로 클램프(평균 100 → 0, 200% → 25)
    • + *
    • 재방문: revisitCount × 8 을 [0,20] 로 클램프
    • + *
    • 결정권자: 10 (해당 시)
    • + *
    • 자료열람: docDownloads × 5 를 [0,5] 로 클램프
    • + *
    + * 스코어는 [0,100] 로 최종 클램프한다. AI 연동은 별도 어드바이저가 이 결과를 보정한다. + */ +@Component +public class RuleBasedLeadScorer implements LeadScorer { + + static final int W_INTEREST = 8; // per interest point (max 40 at interest=5) + static final int MAX_DWELL = 25; + static final int MAX_REVISIT = 20; + static final int W_REVISIT = 8; + static final int P_DECISION_MAKER = 10; + static final int MAX_DOC = 5; + static final int W_DOC = 5; + + @Override + public ScoreResult score(LeadSignals s) { + List reasons = new ArrayList<>(); + + int interest = clamp(s.interest(), 1, 5); + int interestPts = interest * W_INTEREST; + if (interest >= 4) { + reasons.add("상담원 관심도 " + interest + "점(상)으로 구매 의향 높음"); + } else if (interest >= 3) { + reasons.add("상담원 관심도 " + interest + "점(중)"); + } + + int dwellPts = clamp((int) Math.round((s.dwellRatioPct() - 100) * 0.25), 0, MAX_DWELL); + if (s.dwellRatioPct() >= 150) { + reasons.add("부스 체류 시간 평균 대비 " + s.dwellRatioPct() + "%"); + } + + int revisitPts = clamp(s.revisitCount() * W_REVISIT, 0, MAX_REVISIT); + if (s.revisitCount() >= 1) { + reasons.add("부스 재방문 " + s.revisitCount() + "회"); + } + + int dmPts = s.decisionMaker() ? P_DECISION_MAKER : 0; + if (s.decisionMaker()) { + reasons.add("구매 결정권자 직급으로 확인"); + } + + int docPts = clamp(s.docDownloads() * W_DOC, 0, MAX_DOC); + if (s.docDownloads() >= 1) { + reasons.add("기술 자료 열람/다운로드 " + s.docDownloads() + "건"); + } + + int score = clamp(interestPts + dwellPts + revisitPts + dmPts + docPts, 0, 100); + if (reasons.isEmpty()) { + reasons.add("기본 관심도 기반 스코어"); + } + return new ScoreResult(score, reasons, "rule"); + } + + private static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(v, max)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/ScoreResult.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/ScoreResult.java new file mode 100644 index 0000000..952eeef --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/scoring/ScoreResult.java @@ -0,0 +1,13 @@ +package com.zioinfo.kintex.visitor.scoring; + +import java.util.List; + +/** + * 리드 스코어 산출 결과. + * + * @param score 0~100 종합 스코어 + * @param reasons 근거 문구(가중치가 실린 신호만, 표시·감사용) + * @param source 산출 출처("rule" = 규칙 엔진, "ai" = Claude 보정) + */ +public record ScoreResult(int score, List reasons, String source) { +} diff --git a/src/backend/src/main/resources/db/migration/V21__checkin_lead_cms_extensions.sql b/src/backend/src/main/resources/db/migration/V21__checkin_lead_cms_extensions.sql new file mode 100644 index 0000000..dc720bd --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V21__checkin_lead_cms_extensions.sql @@ -0,0 +1,61 @@ +-- V21: 2차 웨이브 확장 — 체크인·리드 확장(M10) + CMS 버전 이력·미디어(M17) +-- V17/V19 는 이미 dev 서버에 적용(Flyway 체크섬 고정) → 확장분을 본 마이그레이션으로 분리. 전부 멱등. + + +-- ── 체크인·배지 QR 확장(멱등 ALTER) ───────────────────────────────────────── +-- QR 토큰: 배지 코드와 분리된 불투명 검증 토큰(현장 체크인 스캔 대상). checkin_at: 체크인 시각(중복 방지·집계 근거). +ALTER TABLE visitor_registration ADD COLUMN IF NOT EXISTS qr_token varchar(64); +ALTER TABLE visitor_registration ADD COLUMN IF NOT EXISTS checkin_at timestamptz; +-- 기존/시드 행 백필(결정론적 · 데모): 토큰은 id 해시, 완료건은 등록 2시간 후로 가정. +UPDATE visitor_registration SET qr_token = 'QR-' || upper(substr(md5(id), 1, 20)) WHERE qr_token IS NULL; +UPDATE visitor_registration SET checkin_at = registered_at + interval '2 hours' + WHERE checkin_state = 'done' AND checkin_at IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_visitor_reg_qr ON visitor_registration (qr_token) WHERE qr_token IS NOT NULL; + +-- ── 리드 소유·동의·메모 확장(멱등 ALTER) ──────────────────────────────────── +-- exhibitor_id: 소유 참가업체(수집 주체) · agree_privacy: 개인정보 수집 동의 · note: 상담 메모. +ALTER TABLE lead ADD COLUMN IF NOT EXISTS exhibitor_id varchar(40); +ALTER TABLE lead ADD COLUMN IF NOT EXISTS agree_privacy boolean NOT NULL DEFAULT true; +ALTER TABLE lead ADD COLUMN IF NOT EXISTS note text; +CREATE INDEX IF NOT EXISTS idx_lead_exhibitor ON lead (event_id, exhibitor_id); + + +-- ── 콘텐츠 버전 이력(SCR-35 우측 버전 이력·롤백) ──────────────────────────────── +-- 콘텐츠 본문/제목 저장·상태 게시·롤백 시점마다 스냅샷 1행. version_no 는 콘텐츠별 단조 증가. +CREATE TABLE IF NOT EXISTS cms_content_version ( + id varchar(40) PRIMARY KEY, + content_id varchar(40) NOT NULL REFERENCES cms_content(id) ON DELETE CASCADE, + version_no int NOT NULL, + title varchar(300), + body text, + status varchar(20), + author_name varchar(120), + note varchar(200), -- 스냅샷 사유(created|edited|published|rollback...) + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (content_id, version_no) +); +CREATE INDEX IF NOT EXISTS idx_cms_version_content ON cms_content_version(content_id, version_no DESC); + +-- ── 미디어 라이브러리(SCR-35 중앙 미디어 스트립) ────────────────────────────── +-- 기존 업로드 패턴(login-slides) 재사용 — 파일은 {kintex.upload.dir}/cms/ 아래 UUID 저장, /uploads/cms/** 서빙. +CREATE TABLE IF NOT EXISTS cms_media ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + file_name varchar(300) NOT NULL, -- 표시용 원본 파일명(신뢰 안 함·표시 전용) + url varchar(500) NOT NULL, -- /uploads/cms/{uuid.ext} + content_type varchar(80), + size_bytes bigint, + alt_text varchar(300), -- 대체 텍스트(접근성·AI 태깅 예정) + uploader_name varchar(120), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_cms_media_created ON cms_media(created_at DESC); + +-- 버전 시드(멱등): 게시 워크플로 데모용 — cms-about 3버전, cms-home 1버전. +INSERT INTO cms_content_version (id, content_id, version_no, title, body, status, author_name, note) +VALUES + ('cv-about-1', 'cms-about', 1, '행사 소개(초안)', '스마트 팩토리 엑스포 초안 본문.', 'draft', '관리자', 'created'), + ('cv-about-2', 'cms-about', 2, '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개(보강).', 'review', '관리자', 'edited'), + ('cv-about-3', 'cms-about', 3, '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'review', '관리자', 'edited'), + ('cv-home-1', 'cms-home', 1, '페이지', '전시 공식 홈 페이지 본문', 'published','관리자', 'published') +ON CONFLICT (content_id, version_no) DO NOTHING; diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionScoringTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionScoringTest.java new file mode 100644 index 0000000..e954019 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auction/AuctionScoringTest.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.Weights; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * M15 낙찰 종합점수 산식 단위테스트 — 가격·평판·납기 정규화 가중합(SCR-29 비교표 스코어). + * 최저가·최단납기·최고평판이 각 축 100점이며, comprehensive 는 가중 평균·lowest 는 가격만 사용함을 고정. + */ +class AuctionScoringTest { + + @Test + void lowestCriteria_usesPriceScoreOnly() { + Weights w = new Weights(60, 25, 15); // lowest 에선 가중치 무시 + // 최저가(8.4M)=100점, 8.7M=8.4/8.7*100=96.6 + assertEquals(100.0, AuctionScoring.score("lowest", w, 8_400_000L, 8_400_000L, 12, 10, 4.8), 1e-9); + assertEquals(96.6, AuctionScoring.score("lowest", w, 8_700_000L, 8_400_000L, 10, 10, 4.6), 1e-9); + } + + @Test + void comprehensive_weightedAverageOfNormalizedAxes() { + Weights w = new Weights(60, 25, 15); + // 업체 A: 최저가 8.4M(가격 100), 평판 4.8(=96), 납기 12일(최단 10 → 83.3) + // score = (100*60 + 96*25 + 83.3*15)/100 = (6000 + 2400 + 1249.5)/100 = 96.495 → 96.5 + double a = AuctionScoring.score("comprehensive", w, 8_400_000L, 8_400_000L, 12, 10, 4.8); + assertEquals(96.5, a, 1e-9); + + // 업체 B: 8.7M(가격 96.55), 평판 4.6(=92), 납기 10일 최단(100) + // score = (96.5517*60 + 92*25 + 100*15)/100 = (5793.10 + 2300 + 1500)/100 = 95.931 → 95.9 + double b = AuctionScoring.score("comprehensive", w, 8_700_000L, 8_400_000L, 10, 10, 4.6); + assertEquals(95.9, b, 1e-9); + + assertTrue(a > b, "최저가+최고평판 업체 A 가 종합 우위여야 한다"); + } + + @Test + void zeroWeights_doNotDivideByZero() { + Weights w = new Weights(0, 0, 0); + double s = AuctionScoring.score("comprehensive", w, 5_000_000L, 5_000_000L, 8, 8, 4.0); + assertTrue(s >= 0.0 && s <= 100.0); + } + + @Test + void axisScores_bounded0to100() { + assertEquals(100.0, AuctionScoring.priceScore(1000L, 1000L), 1e-9); + assertTrue(AuctionScoring.priceScore(2000L, 1000L) < 100.0); + assertEquals(100.0, AuctionScoring.reputationScore(5.0), 1e-9); + assertEquals(80.0, AuctionScoring.reputationScore(4.0), 1e-9); + assertEquals(100.0, AuctionScoring.deliveryScore(10, 10), 1e-9); + assertTrue(AuctionScoring.deliveryScore(20, 10) < 100.0); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auction/QuotationCalcTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auction/QuotationCalcTest.java new file mode 100644 index 0000000..d4726be --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auction/QuotationCalcTest.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.QuoteLine; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * M15 견적서 서버 재계산 단위테스트 — 소계=Σ(수량×단가)·부가세 10%·총액·검증. + * 클라이언트가 보낸 금액을 신뢰하지 않고 서버가 라인아이템으로 재산출함을 고정한다. + */ +class QuotationCalcTest { + + @Test + void fromLines_sumsQtyTimesUnit_andAppliesVat() { + // 골조 48×45,000 + 전기 1×1,240,000 + 그래픽 12×95,000 = 2,160,000 + 1,240,000 + 1,140,000 = 4,540,000 + List lines = List.of( + new QuoteLine("골조", "알루미늄 프로파일", 48L, 45_000L), + new QuoteLine("전기", "분전반 일체", 1L, 1_240_000L), + new QuoteLine("그래픽", "출력·시공", 12L, 95_000L)); + + QuotationCalc.Totals t = QuotationCalc.fromLines(lines); + + assertEquals(4_540_000L, t.subtotal()); // 부가세 별도 경쟁 금액 + assertEquals(454_000L, t.vat()); // 10% + assertEquals(4_994_000L, t.grandTotal()); // 소계 + 부가세 + } + + @Test + void fromLines_roundsVatHalfUp() { + // 소계 8,399,999 → 부가세 round(839,999.9)=840,000 + QuotationCalc.Totals t = QuotationCalc.fromLines( + List.of(new QuoteLine("일식", "복합공사", 1L, 8_399_999L))); + assertEquals(8_399_999L, t.subtotal()); + assertEquals(840_000L, t.vat()); + assertEquals(9_239_999L, t.grandTotal()); + } + + @Test + void fromTotal_treatsAmountAsExVatSubtotal() { + QuotationCalc.Totals t = QuotationCalc.fromTotal(8_400_000L); + assertEquals(8_400_000L, t.subtotal()); + assertEquals(840_000L, t.vat()); + assertEquals(9_240_000L, t.grandTotal()); + } + + @Test + void rejectsNegativeQuantityOrUnitPrice() { + ApiException e1 = assertThrows(ApiException.class, + () -> QuotationCalc.fromLines(List.of(new QuoteLine("골조", "자재", -1L, 45_000L)))); + assertEquals(ErrorCode.VALIDATION, e1.getCode()); + + assertThrows(ApiException.class, + () -> QuotationCalc.fromLines(List.of(new QuoteLine("골조", "자재", 5L, -100L)))); + } + + @Test + void rejectsEmptyLinesAndZeroSubtotal() { + assertThrows(ApiException.class, () -> QuotationCalc.fromLines(List.of())); + assertThrows(ApiException.class, + () -> QuotationCalc.fromLines(List.of(new QuoteLine("무상", "샘플", 0L, 0L)))); + assertThrows(ApiException.class, () -> QuotationCalc.fromTotal(0L)); + assertThrows(ApiException.class, () -> QuotationCalc.fromTotal(null)); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java new file mode 100644 index 0000000..4639128 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java @@ -0,0 +1,152 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.cms.dto.CmsContentUpdateRequest; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역 501. + */ +class CmsServiceTest { + + private CmsMapper mapper; + private SystemAccessGuard scope; + private CmsService service; + + private final KintexPrincipal editor = + new KintexPrincipal("u1", "편집자", Map.of(), false); + + @BeforeEach + void setUp() { + mapper = mock(CmsMapper.class); + scope = mock(SystemAccessGuard.class); + service = new CmsService(mapper, scope, "./build/test-uploads"); + } + + private Map contentRow(String status) { + Map r = new HashMap<>(); + r.put("id", "cms-1"); + r.put("title", "제목"); + r.put("body", "본문"); + r.put("status", status); + r.put("contentType", "PAGE"); + return r; + } + + // ── 상태 전이 가드 ───────────────────────────────────────────────────────── + + @Test + void transitionRejectsBackwardMove() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("published")); + ApiException ex = assertThrows(ApiException.class, + () -> service.transition(editor, "cms-1", "review")); + assertEquals(ErrorCode.VALIDATION, ex.getCode()); + verify(mapper, never()).updateStatus(anyString(), anyString()); + } + + @Test + void transitionRejectsUnknownStatus() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + ApiException ex = assertThrows(ApiException.class, + () -> service.transition(editor, "cms-1", "archived")); + assertEquals(ErrorCode.VALIDATION, ex.getCode()); + } + + @Test + void publishTransitionRequiresManagerAndSnapshots() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("approved")); + when(mapper.maxVersionNo("cms-1")).thenReturn(2); + service.transition(editor, "cms-1", "published"); + verify(scope).requireManager(editor); // 게시는 매니저↑ 게이트 + verify(mapper).updateStatus("cms-1", "published"); + verify(mapper).insertVersion(any()); // 게시 시 버전 스냅샷 + } + + @Test + void reviewRequestNeedsNoManagerGate() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + service.transition(editor, "cms-1", "review"); + verify(scope, never()).requireManager(any()); + verify(mapper).updateStatus("cms-1", "review"); + } + + // ── 본문 sanitize + 버전 스냅샷 ──────────────────────────────────────────── + + @Test + void updateSanitizesBodyBeforePersistAndSnapshots() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + when(mapper.maxVersionNo("cms-1")).thenReturn(0); + var req = new CmsContentUpdateRequest( + "제목", "

    안전

    ", null, true, false); + + service.update(editor, "cms-1", req); + + ArgumentCaptor> cap = ArgumentCaptor.forClass(Map.class); + verify(mapper).updateContent(cap.capture()); + String storedBody = String.valueOf(cap.getValue().get("body")); + assertTrue(storedBody.contains("

    안전

    "), "허용 태그는 보존"); + assertFalse(storedBody.toLowerCase().contains(" service.update(editor, "cms-1", req)); + assertEquals(ErrorCode.VALIDATION, ex.getCode()); + } + + // ── 롤백 ─────────────────────────────────────────────────────────────────── + + @Test + void rollbackRestoresVersionAndSnapshots() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("published")); + Map ver = new HashMap<>(); + ver.put("title", "예전제목"); + ver.put("body", "예전본문"); + ver.put("status", "review"); + when(mapper.findVersion("cms-1", 2)).thenReturn(ver); + when(mapper.maxVersionNo("cms-1")).thenReturn(4); + + service.rollback(editor, "cms-1", 2); + + verify(scope).requireManager(editor); + ArgumentCaptor> cap = ArgumentCaptor.forClass(Map.class); + verify(mapper).restoreContent(cap.capture()); + assertEquals("예전제목", cap.getValue().get("title")); + assertEquals("review", cap.getValue().get("status")); + verify(mapper).insertVersion(any()); // 롤백 스냅샷 + } + + @Test + void rollbackMissingVersionThrowsNotFound() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + when(mapper.findVersion("cms-1", 9)).thenReturn(null); + ApiException ex = assertThrows(ApiException.class, () -> service.rollback(editor, "cms-1", 9)); + assertEquals(ErrorCode.NOT_FOUND, ex.getCode()); + } + + // ── AI 번역 스텁 501 ─────────────────────────────────────────────────────── + + @Test + void aiTranslateNotImplementedReturns501Code() { + when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft")); + ApiException ex = assertThrows(ApiException.class, () -> service.aiTranslate("cms-1", "en")); + assertEquals(ErrorCode.NOT_IMPLEMENTED, ex.getCode()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/common/text/HtmlSanitizerTest.java b/src/backend/src/test/java/com/zioinfo/kintex/common/text/HtmlSanitizerTest.java new file mode 100644 index 0000000..6a9470f --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/common/text/HtmlSanitizerTest.java @@ -0,0 +1,56 @@ +package com.zioinfo.kintex.common.text; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CMS 본문 XSS sanitize(N2) 단위테스트 — 위험 요소 제거·허용 서식 보존. + */ +class HtmlSanitizerTest { + + @Test + void removesScriptTagAndContent() { + String out = HtmlSanitizer.sanitize("

    안녕

    "); + assertTrue(out.contains("

    안녕

    ")); + assertFalse(out.toLowerCase().contains("링크"); + assertFalse(out.toLowerCase().contains("onclick")); + assertFalse(out.contains("steal()")); + assertTrue(out.contains("링크")); + assertTrue(out.contains("href=\"/ok\""), "안전한 href 는 보존"); + } + + @Test + void removesJavascriptSchemeUrls() { + String out = HtmlSanitizer.sanitize("x"); + assertFalse(out.toLowerCase().contains("javascript:")); + assertTrue(out.contains("x")); + } + + @Test + void dropsDisallowedTagsButKeepsText() { + String out = HtmlSanitizer.sanitize("흐름"); + assertFalse(out.toLowerCase().contains(" s.contains("240%"))); + assertTrue(r.reasons().stream().anyMatch(s -> s.contains("결정권자"))); + } + + @Test + void coldLead_lowInterestOnly() { + // interest 1(=8) + dwell 80%→clamp(round(-5),0)=0 + revisit 0 + no dm + no doc = 8 + ScoreResult r = scorer.score(new LeadSignals(1, 80, 0, false, 0)); + assertEquals(8, r.score()); + assertTrue(r.score() >= 0 && r.score() <= 100); + } + + @Test + void scoreIsClampedTo100_andMonotonicInInterest() { + // 모든 신호 최대 → 40+25+20+10+5 = 100, 상한 유지. + ScoreResult max = scorer.score(new LeadSignals(5, 300, 5, true, 5)); + assertEquals(100, max.score()); + + // 관심도 단조 증가(다른 신호 고정): 1점 < 3점 < 5점. + int s1 = scorer.score(LeadSignals.ofInterest(1)).score(); + int s3 = scorer.score(LeadSignals.ofInterest(3)).score(); + int s5 = scorer.score(LeadSignals.ofInterest(5)).score(); + assertTrue(s1 < s3 && s3 < s5, "관심도가 높을수록 스코어가 높아야 한다"); + assertEquals(8, s1); // 1*8 + assertEquals(40, s5); // 5*8 + } + + @Test + void outOfRangeInterestIsClamped() { + // interest 0·9 → [1,5] 로 클램프(방어). + assertEquals(8, scorer.score(new LeadSignals(0, 100, 0, false, 0)).score()); + assertEquals(40, scorer.score(new LeadSignals(9, 100, 0, false, 0)).score()); + } +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java new file mode 100644 index 0000000..871f7ac --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/visitor/VisitorCheckinServiceTest.java @@ -0,0 +1,95 @@ +package com.zioinfo.kintex.visitor; + +import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinRequest; +import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinResult; +import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * M10 현장 체크인 중복 방지 단위테스트. + * 첫 스캔은 done 처리(alreadyCheckedIn=false), 이미 완료된 배지의 재스캔은 재집계 없이 alreadyCheckedIn=true. + */ +class VisitorCheckinServiceTest { + + private static final String EVENT = "e-2026-smf"; + private static final String TOKEN = "QR-ABC123"; + private static final String RID = "vr-smf-006"; + + private VisitorMapper mapper; + private VisitorService service; + + @BeforeEach + void setUp() { + mapper = mock(VisitorMapper.class); + service = new VisitorService(mapper, new RuleBasedLeadScorer()); + } + + private static Map row(String state, String checkinAt) { + Map m = new HashMap<>(); + m.put("registrationId", RID); + m.put("nameMasked", "한*수"); + m.put("type", "visitor"); + m.put("company", "두산로보틱스"); + m.put("badgeCode", "KTX-V-0006"); + m.put("badgeIssued", true); + m.put("checkinState", state); + m.put("checkinAt", checkinAt); + return m; + } + + @Test + void firstScan_marksCheckedIn() { + // 토큰 해소 → waiting. 갱신 1행. 재조회 → done. + when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("waiting", null)); + when(mapper.markCheckedIn(EVENT, RID)).thenReturn(1); + when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30")); + + CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null)); + + assertFalse(res.alreadyCheckedIn(), "첫 체크인은 중복이 아니다"); + assertEquals("done", res.checkinState()); + assertEquals("한*수", res.nameMasked()); // 마스킹 필드만 노출 + verify(mapper, times(1)).markCheckedIn(EVENT, RID); + } + + @Test + void duplicateScan_doesNotRecount() { + // 이미 done → markCheckedIn 호출 금지, alreadyCheckedIn=true. + when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("done", "2026.08.11 09:30")); + when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30")); + + CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null)); + + assertTrue(res.alreadyCheckedIn(), "이미 완료된 배지 재스캔은 중복으로 표시"); + assertEquals("done", res.checkinState()); + verify(mapper, never()).markCheckedIn(EVENT, RID); + } + + @Test + void raceCondition_updateReturnsZero_treatedAsAlready() { + // 조회 시엔 waiting 이었으나 동시 요청으로 UPDATE 0행 → 이미 완료로 간주(중복). + when(mapper.findForCheckin(eq(EVENT), eq(TOKEN), isNull())).thenReturn(row("waiting", null)); + when(mapper.markCheckedIn(EVENT, RID)).thenReturn(0); + when(mapper.findForCheckin(eq(EVENT), isNull(), eq(RID))).thenReturn(row("done", "2026.08.11 09:30")); + + CheckinResult res = service.checkin(EVENT, new CheckinRequest(TOKEN, null)); + + assertTrue(res.alreadyCheckedIn()); + verify(mapper, times(1)).markCheckedIn(EVENT, RID); + } +} diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 4105604..f747f13 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -40,6 +40,7 @@ import { AwardComparePage } from './screens/auction/AwardComparePage'; import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage'; import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage'; import { LeadScoringPage } from './screens/visitor/LeadScoringPage'; +import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage'; import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage'; import { SponsorshipPage } from './screens/marketing/SponsorshipPage'; import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage'; @@ -176,6 +177,7 @@ export function App() { } /> {/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */} } /> + } /> } /> } /> } /> diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 6703085..82af95a 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -42,6 +42,7 @@ const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType; { key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' }, { key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' }, { key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' }, + { key: 'checkin', label: '체크인 데스크', Icon: IconCheckCircle, to: '/visitors/checkin' }, { key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' }, { key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' }, { key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' }, diff --git a/src/frontend/src/screens/auction/AuctionDetailPage.tsx b/src/frontend/src/screens/auction/AuctionDetailPage.tsx index 3857c7b..44fba49 100644 --- a/src/frontend/src/screens/auction/AuctionDetailPage.tsx +++ b/src/frontend/src/screens/auction/AuctionDetailPage.tsx @@ -1,39 +1,45 @@ /* - * SCR-27 옥션 상세·실시간 순위·응찰 [M15]. Stitch scr_27_auction_detail 이식. - * 대상: 장치업체(응찰). 좌 AI 자료 뷰어(배치·설계·BOQ·예상이미지) + 우 실시간 순위·응찰. - * ★ M15 엔진 미구현 → SAMPLE_RANKS 정적 데이터. 실 API/WebSocket 없음(카운트다운은 로컬 데모). - * 보안(봉인 입찰): 마감 전 경쟁 견적 금액 비공개 — 현재 최저가(공개 벤치마크)와 내 견적만 노출, - * 그 외 타사 금액은 "비공개" 마스킹. "익명 순위" 토글로 순위만 확인. - * 응찰은 등록업체 게이트(안내) + 로컬 상태 데모("백엔드 연동 예정"). + * SCR-27 옥션 상세·실시간 순위·응찰 [M15]. 실 API 전환(폴링). + * 정본: GET /api/auctions/{id} (AuctionDetail) · POST /{id}/bids · POST /{id}/close. + * 대상: 장치업체(응찰)·발주자. 좌 AI 자료 뷰어 + 우 봉인 순위·응찰. + * ★ 봉인 입찰(서버 강제): 응답의 ranking 은 이미 마스킹됨 — 타사 금액/업체명 없음. + * 화면은 서버가 준 priceMasked/price 만 렌더(클라이언트 언마스킹 없음). refetchInterval 5초. */ import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '../../components/ui/Button'; import { AiLabel } from '../../components/ui/Badge'; +import { ErrorState, Skeleton } from '../../components/ui/States'; import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons'; import { - REFERENCE_TABS, - SAMPLE_RANKS, + auctionApi, formatWon, + type AuctionDetail, type MaterialKind, type RankRow, -} from './sampleAuction'; -import { SampleBadge, useToast } from './aucShared'; +} from './auctionApi'; +import { errMessage, useToast } from './aucShared'; import './auction.css'; -const REF_DESC: Record = { +const REF_DESC: Record = { layout: { + label: '배치도', title: '배치도 (M2)', - desc: 'A-102 부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.', + desc: '부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.', }, design: { + label: '부스 설계안', title: '부스 설계안 (M3)', desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.', }, boq: { + label: '물량서(BOQ)', title: '물량서 (M4 · BOQ)', desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.', }, aiimage: { + label: '예상 이미지', title: '예상 이미지 (M5)', desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.', ai: true, @@ -41,42 +47,116 @@ const REF_DESC: Record('aiimage'); + const [tab, setTab] = useState('layout'); const [anon, setAnon] = useState(true); - const [seconds, setSeconds] = useState(5076); // 01:24:36 + const [seconds, setSeconds] = useState(0); + const [bidPrice, setBidPrice] = useState(''); + const [leadDays, setLeadDays] = useState(''); + const q = useQuery({ + queryKey: ['auction', auctionId], + queryFn: () => auctionApi.detail(auctionId), + enabled: !!auctionId, + retry: false, + refetchInterval: 5000, + }); + const detail = q.data; + + // 카운트다운(로컬 tick) — deadline 기준. detail 갱신 시 재동기화. useEffect(() => { - const id = window.setInterval(() => setSeconds((s) => (s > 0 ? s - 1 : 0)), 1000); + if (!detail?.deadline) return; + const target = new Date(detail.deadline).getTime(); + const sync = () => setSeconds(Math.max(0, Math.floor((target - Date.now()) / 1000))); + sync(); + const id = window.setInterval(sync, 1000); return () => window.clearInterval(id); - }, []); + }, [detail?.deadline]); + + // 자료 탭 초기값 = 존재하는 첫 자료 + useEffect(() => { + if (detail && detail.materials.length && !detail.materials.includes(tab)) { + setTab(detail.materials[0]); + } + }, [detail, tab]); + + const bidM = useMutation({ + mutationFn: () => + auctionApi.bid(auctionId, { + total: Number(bidPrice), + leadDays: leadDays ? Number(leadDays) : undefined, + }), + onSuccess: (r) => { + show(`응찰 완료 — 현재 ${r.myRank ?? '-'}위 (v${r.version})`); + setBidPrice(''); + qc.invalidateQueries({ queryKey: ['auction', auctionId] }); + }, + onError: (e) => show(errMessage(e)), + }); + + const closeM = useMutation({ + mutationFn: () => auctionApi.close(auctionId), + onSuccess: () => { + show('라운드를 마감했습니다. 봉인이 해제되어 견적 비교가 가능합니다.'); + qc.invalidateQueries({ queryKey: ['auction', auctionId] }); + }, + onError: (e) => show(errMessage(e)), + }); + + if (q.isLoading) { + return ( +
    + +
    + +
    + ); + } + if (q.isError || !detail) { + return ( +
    + q.refetch()} /> +
    + ); + } const active = REF_DESC[tab]; + const closed = detail.status !== '진행중'; return ( -
    +
    {/* 상단 컨텍스트 바 */}
    - - + {!closed && ( + + + )}

    - A-102 독립부스 시공 + {detail.title}

    - 역경매 - 라운드 2 - + {detail.type} + 라운드 {detail.round}
    상태 - 활성 응찰 중 + + {detail.status === '진행중' ? '활성 응찰 중' : detail.status} +
    현재 최저가 - {formatWon(8_400_000)} + + {detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'} +
    @@ -85,15 +165,15 @@ export function AuctionDetailPage() { {/* 좌 — AI 자료 뷰어 */}
    - {REFERENCE_TABS.map((t) => ( + {detail.materials.map((kind) => ( ))}
    @@ -110,7 +190,7 @@ export function AuctionDetailPage() { type="button" className="kx-viewer__tab" style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }} - onClick={() => show('전체화면 뷰어 — 백엔드 연동 예정')} + onClick={() => show('전체화면 뷰어는 자료 스토리지 연동 후 제공됩니다.')} aria-label="전체화면" > @@ -137,30 +217,106 @@ export function AuctionDetailPage() {
    - 봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다. + {detail.sealed + ? '봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다.' + : '마감됨 — 발주자 견적 비교에서 전체가 공개됩니다.'}
    - {SAMPLE_RANKS.map((r) => ( - - ))} + {detail.ranking.length === 0 ? ( +

    + 아직 응찰이 없습니다. +

    + ) : ( + detail.ranking.map((r) => ) + )}
    -
    - 라운드 종료까지 - {fmtClock(seconds)} -
    - -

    - 재응찰 시 이전 응찰 정보는 자동 대체됩니다. 킨텍스 등록업체만 응찰할 수 있습니다. -

    + {!closed && ( +
    + 라운드 종료까지 + {fmtClock(seconds)} +
    + )} + + {detail.canBid ? ( + <> +
    +
    + + setBidPrice(e.target.value)} + /> +
    +
    + + setLeadDays(e.target.value)} + /> +
    +
    + +

    + 재응찰 시 이전 응찰 정보는 자동 대체됩니다(버전 증가). 킨텍스 등록업체만 응찰할 수 + 있습니다. +

    + + ) : ( +

    + {closed + ? '마감된 옥션입니다. 응찰이 종료되었습니다.' + : '응찰 권한이 없습니다 — 킨텍스 등록 장치업체(초대 대상)만 응찰할 수 있습니다.'} +

    + )} + + {detail.canViewAward && ( + + )} + {!closed && detail.canViewAward === false && isOrdererHint(detail) && ( + + )}
    @@ -169,17 +325,22 @@ export function AuctionDetailPage() { ); } +/** 발주자이면서 아직 마감 전이라 close 버튼을 노출할지 힌트. canBid=false + !canViewAward + 응찰 불가 상태에서 노출. */ +function isOrdererHint(d: AuctionDetail): boolean { + // 발주자 판단은 서버가 canViewAward(마감 후)로만 노출하므로, 마감 전에는 close 버튼을 항상 시도 가능하게 둔다 + // (권한 없으면 서버가 403 → 토스트). 응찰 가능한 업체에는 노출하지 않는다. + return !d.canBid; +} + function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) { - // 봉인 규칙: 내 견적 + 현재 최저가(공개 벤치마크)만 금액 노출, 그 외 타사는 마스킹. - const revealPrice = row.isMe || row.isLowest; const cls = row.isMe ? 'kx-rank__row--me' : row.isLowest ? 'kx-rank__row--lowest' : ''; - const name = anon ? (row.isMe ? '나의 응찰 (ME)' : row.alias) : row.isMe ? '나의 응찰 (ME)' : `(주)협력사 ${row.rank}`; + const name = row.isMe ? '나의 응찰 (ME)' : anon ? row.alias : `(주)협력사 ${row.rank}`; return (
    {row.rank}

    {name}

    - {revealPrice ? ( + {!row.priceMasked && row.price != null ? (

    {formatWon(row.price)}

    ) : (

    @@ -189,7 +350,10 @@ function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {

    {row.isLowest && 최저가} {row.isMe && ( - + 나의 순위 )} diff --git a/src/frontend/src/screens/auction/AuctionListPage.tsx b/src/frontend/src/screens/auction/AuctionListPage.tsx index e329b90..cfdd018 100644 --- a/src/frontend/src/screens/auction/AuctionListPage.tsx +++ b/src/frontend/src/screens/auction/AuctionListPage.tsx @@ -1,42 +1,45 @@ /* - * SCR-26 공사/장치 옥션 목록·개설 [M15]. Stitch scr_26_auction_list 이식. + * SCR-26 공사/장치 옥션 목록·개설 [M15]. 실 API 전환. + * 정본: GET /api/auctions (AuctionSummary[]) · POST /api/auctions (개설 — 발주자 권한, 서버 검증). * 대상: 참가업체·주최자(발주). 좌 옥션 카드 목록 + 우 옥션 개설 패널. - * ★ M15 엔진 미구현 → SAMPLE_AUCTIONS 정적 데이터("샘플 데이터" 배지). 실 API fetch 없음. - * 개설·초대는 로컬 상태 데모 + "백엔드 연동 예정" 안내. - * 보안: 등록업체만 초대 가능(verified 게이트) — 미등록 업체 체크박스 비활성. + * 보안: 개설·초대는 등록업체 게이트가 서버에서 강제(미등록 초대 id 는 서버가 필터). 발주 권한 없으면 403. */ import { useMemo, useState, type ReactNode } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; import { Button } from '../../components/ui/Button'; import { DdayChip } from '../../components/ui/Badge'; -import { EmptyState } from '../../components/ui/States'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { IconPlus, IconDocument, IconFloorplan, IconImage, - IconSpark, IconSettings, } from '../../components/ui/icons'; import { - INVITABLE_COMPANIES, MATERIAL_LABEL, - SAMPLE_AUCTIONS, + auctionApi, formatWon, type AuctionStatus, type AuctionSummary, type AwardCriteria, + type AuctionType, type MaterialKind, -} from './sampleAuction'; -import { SampleBadge, useToast } from './aucShared'; +} from './auctionApi'; +import { errMessage, useToast } from './aucShared'; import './auction.css'; type TabKey = 'live' | 'closed' | 'mine'; const TABS: { key: TabKey; label: string }[] = [ { key: 'live', label: '진행중' }, { key: 'closed', label: '마감' }, - { key: 'mine', label: '내가 개설' }, + { key: 'mine', label: '역경매' }, ]; +const CATEGORIES = ['전시디자인설치', '구조물 임대', '전기/조명', '영상/음향', '네트워크']; +const MATERIAL_KINDS: MaterialKind[] = ['layout', 'design', 'boq', 'aiimage']; + const MAT_ICON: Record = { layout: , design: , @@ -45,28 +48,77 @@ const MAT_ICON: Record = { }; export function AuctionListPage() { + const qc = useQueryClient(); + const navigate = useNavigate(); const { show, node: toast } = useToast(); const [tab, setTab] = useState('live'); + + const q = useQuery({ + queryKey: ['auctions'], + queryFn: () => auctionApi.list(), + retry: false, + refetchInterval: 15000, + }); + const auctions = q.data ?? []; + + // 개설 폼 상태 + const eventIds = useMemo( + () => Array.from(new Set(auctions.map((a) => a.eventId))).filter(Boolean), + [auctions], + ); + const [eventId, setEventId] = useState(''); + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(CATEGORIES[0]); + const [type, setType] = useState('역경매'); const [criteria, setCriteria] = useState('comprehensive'); const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 }); - const [invited, setInvited] = useState>({ - '(주)에이치디자인': true, + const [round, setRound] = useState(1); + const [deadline, setDeadline] = useState(''); + const [materials, setMaterials] = useState>(new Set(MATERIAL_KINDS)); + + const effectiveEventId = eventId || eventIds[0] || ''; + + const createM = useMutation({ + mutationFn: () => + auctionApi.create({ + eventId: effectiveEventId, + title: title.trim(), + category, + type, + awardCriteria: criteria, + weights, + round, + deadline, + materials: Array.from(materials), + invitedCompanyIds: [], // 공개 옥션(등록업체 응찰). 초대 세분은 후속. + }), + onSuccess: () => { + show('옥션 공고를 게시했습니다.'); + setTitle(''); + setDeadline(''); + qc.invalidateQueries({ queryKey: ['auctions'] }); + }, + onError: (e) => show(errMessage(e)), }); const visible = useMemo(() => { - if (tab === 'closed') return SAMPLE_AUCTIONS.filter((a) => a.status !== '진행중'); - if (tab === 'mine') return SAMPLE_AUCTIONS.filter((a) => a.type === '역경매'); - return SAMPLE_AUCTIONS.filter((a) => a.status === '진행중'); - }, [tab]); + if (tab === 'closed') return auctions.filter((a) => a.status !== '진행중'); + if (tab === 'mine') return auctions.filter((a) => a.type === '역경매'); + return auctions.filter((a) => a.status === '진행중'); + }, [tab, auctions]); + + function submitCreate() { + if (!effectiveEventId) return show('행사를 선택해 주세요.'); + if (!title.trim()) return show('공고명을 입력해 주세요.'); + if (!deadline) return show('마감 기한을 선택해 주세요.'); + createM.mutate(); + } return ( -
    +
    -
    -

    공사·장치 옥션

    - -
    +

    공사·장치 옥션

    AI 설계자료(M2~M5) 기반 역경매 · 등록업체 응찰

    @@ -89,14 +141,26 @@ export function AuctionListPage() {
    {/* 좌 — 옥션 카드 목록 */}
    - {visible.length === 0 ? ( + {q.isLoading && ( + <> + + + + )} + {q.isError && !q.isLoading && ( + q.refetch()} /> + )} + {!q.isLoading && !q.isError && visible.length === 0 && ( - ) : ( - visible.map((a) => show(`${a.title} 상세 — 백엔드 연동 예정`)} />) )} + {!q.isLoading && + !q.isError && + visible.map((a) => ( + navigate(`/auctions/${a.id}`)} /> + ))}
    {/* 우 — 옥션 개설 패널 */} @@ -109,18 +173,77 @@ export function AuctionListPage() {
    -
    +
    + + setTitle(e.target.value)} + /> +
    + +
    +
    + + +
    +
    + 옥션 유형 +
    + + +
    +
    +
    +
    낙찰 기준 (Award Criteria)
    @@ -168,66 +291,64 @@ export function AuctionListPage() { - + setRound(Math.max(1, Number(e.target.value) || 1))} + />
    - + setDeadline(e.target.value)} + />
    - 설계 데이터 연동 - -
    - -
    - -
    - {INVITABLE_COMPANIES.map((c) => ( -
    +
    + 킨텍스 등록업체만 응찰할 수 있습니다. 미등록 업체 초대는 서버에서 차단됩니다. +
    +
    @@ -319,21 +440,19 @@ function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: ()
    - {!closed && ( -
    -
    - {auction.materials.map((m) => ( - - {MAT_ICON[m]} - {MATERIAL_LABEL[m]} - - ))} -
    - +
    +
    + {auction.materials.map((m) => ( + + {MAT_ICON[m]} + {MATERIAL_LABEL[m]} + + ))}
    - )} + +
    ); } diff --git a/src/frontend/src/screens/auction/AwardComparePage.tsx b/src/frontend/src/screens/auction/AwardComparePage.tsx index 39ff7ac..4cff762 100644 --- a/src/frontend/src/screens/auction/AwardComparePage.tsx +++ b/src/frontend/src/screens/auction/AwardComparePage.tsx @@ -1,45 +1,107 @@ /* - * SCR-29 낙찰 비교·선정(Award) [M15]. Stitch scr_29_award_compare 이식. + * SCR-29 낙찰 비교·선정(Award) [M15]. 실 API 전환. + * 정본: GET /api/auctions/{id}/award-view (마감 후 발주자 전용, 봉인 해제) · POST /{id}/award. * 대상: 발주자(주최자·참가업체). 마감 후 견적서 비교 → 낙찰 → 계약·발주 전환. - * ★ M15 엔진 미구현 → SAMPLE_QUOTES 정적 데이터. 낙찰 승인은 로컬 상태 데모("백엔드 연동 예정"). - * 보안: 마감 후 발주자 뷰이므로 전 견적 공개(봉인 해제). 낙찰 사유 필수 입력 후 승인. + * 보안: 마감 전 또는 비발주자는 서버가 403 → 화면은 접근 불가 안내. 낙찰 사유 필수(서버 검증). */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '../../components/ui/Button'; import { AiLabel } from '../../components/ui/Badge'; -import { EmptyState } from '../../components/ui/States'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { IconAiBot, IconCheckCircle, IconDownload, IconWarning } from '../../components/ui/icons'; -import { RISK_BARS, SAMPLE_QUOTES, formatWon, type QuoteColumn } from './sampleAuction'; -import { SampleBadge, useToast } from './aucShared'; +import { auctionApi, formatWon, type Quote } from './auctionApi'; +import { errMessage, useToast } from './aucShared'; import './auction.css'; -export function AwardComparePage() { - const { show, node: toast } = useToast(); - const quotes = SAMPLE_QUOTES; - const recommended = quotes.find((q) => q.recommended) ?? quotes[0]; - const [reason, setReason] = useState(''); - const [awarded, setAwarded] = useState(false); +/** 인라인 별점 아이콘(★ 텍스트 글리프 대체 — stroke SVG). */ +function IconStar({ size = 14 }: { size?: number }) { + return ( + + ); +} - const onAward = () => { - if (!reason.trim()) { - show('선정 사유를 입력해 주세요.'); - return; - } - setAwarded(true); - show(`${recommended.alias} 낙찰 승인 — 계약·발주 전환은 백엔드 연동 예정`); - }; +export function AwardComparePage() { + const { auctionId = '' } = useParams(); + const qc = useQueryClient(); + const { show, node: toast } = useToast(); + const [reason, setReason] = useState(''); + const [selectedBidId, setSelectedBidId] = useState(null); + + const q = useQuery({ + queryKey: ['award-view', auctionId], + queryFn: () => auctionApi.awardView(auctionId), + enabled: !!auctionId, + retry: false, + }); + + const view = q.data; + const quotes = view?.quotes ?? []; + const recommended = useMemo(() => quotes.find((x) => x.recommended) ?? quotes[0], [quotes]); + const alreadyAwarded = !!view?.awarded; + const selected = quotes.find((x) => x.bidId === selectedBidId) ?? recommended; + + const awardM = useMutation({ + mutationFn: () => + auctionApi.award(auctionId, { bidId: selected!.bidId, reason: reason.trim() }), + onSuccess: (r) => { + show(`${r.companyName ?? '선정 업체'} 낙찰 승인 완료 — 계약·발주 전환 대상입니다.`); + qc.invalidateQueries({ queryKey: ['award-view', auctionId] }); + }, + onError: (e) => show(errMessage(e)), + }); + + function onAward() { + if (!selected) return show('선정할 견적을 선택해 주세요.'); + if (!reason.trim()) return show('선정 사유를 입력해 주세요.'); + awardM.mutate(); + } + + if (q.isLoading) { + return ( +
    + +
    + +
    + ); + } + if (q.isError) { + const msg = + (q.error as { code?: string })?.code === 'FORBIDDEN' + ? '마감 후 발주자만 견적 비교·낙찰을 열람할 수 있습니다.' + : '견적 비교 정보를 불러오지 못했습니다.'; + return ( +
    + q.refetch()} /> +
    + ); + } return ( -
    +
    -
    -

    역경매 응찰 비교 및 낙찰

    - -
    -

    A-102 독립부스 시공 · 마감 후 발주자 뷰(봉인 해제)

    +

    역경매 응찰 비교 및 낙찰

    +

    + {view?.auctionTitle ?? ''} · 마감 후 발주자 뷰(봉인 해제) +

    -
    @@ -47,15 +109,19 @@ export function AwardComparePage() {
    공고명 - A-102 독립부스 시공 + {view?.auctionTitle ?? '—'}
    응찰 현황 - 총 {quotes.length}개사 참여 + 총 {view?.bidderCount ?? 0}개사 참여
    낙찰 기준 - 종합평가 (가격 40% + 역량 60%) + + {view?.awardCriteria === 'lowest' + ? '최저가' + : `종합평가 (가격 ${view?.weights.price ?? 0}% · 평판 ${view?.weights.reputation ?? 0}% · 납기 ${view?.weights.delivery ?? 0}%)`} +
    @@ -75,14 +141,21 @@ export function AwardComparePage() { 비교 항목 - {quotes.map((q) => ( + {quotes.map((qc2) => ( - {q.recommended && 최적 추천} - {q.alias} - {q.subtitle} + {qc2.recommended && 최적 추천} + ))} @@ -90,23 +163,25 @@ export function AwardComparePage() { 총액 (VAT 별도) - {quotes.map((q) => ( - - {formatWon(q.totalPrice)} + {quotes.map((qc2) => ( + + + {formatWon(qc2.totalPrice)} + - {q.isLowestPrice ? '▼ 최저가' : `+${q.priceDeltaPct}% 차이`} + {qc2.isLowestPrice ? '최저가' : `+${qc2.priceDeltaPct}% 차이`} ))} 납기 (시공 완료) - {quotes.map((q) => ( - - {q.leadDays}일 - {q.isShortestLead && ( + {quotes.map((qc2) => ( + + {qc2.leadDays}일 + {qc2.isShortestLead && ( 최단 납기 )} @@ -114,27 +189,33 @@ export function AwardComparePage() { 업체 평판 (최근 1년) - {quotes.map((q) => { - const best = q.reputation === Math.max(...quotes.map((x) => x.reputation)); + {quotes.map((qc2) => { + const best = + qc2.reputation === Math.max(...quotes.map((x) => x.reputation)); return ( - -
      - {RISK_BARS.map((b) => ( + {(view?.riskBars ?? []).map((b) => (
    • {b.label} @@ -173,29 +254,20 @@ export function AwardComparePage() {
    • ))}
    -
    - AI 최종 등급 - A+ - 안정적 시공 보장 -
    -

    - 업체 A는 최근 3년 KINTEX 내 유사 규모(100~150㎡) 시공 12회 수행, 클레임 발생률 0%를 - 기록했습니다. -

    {/* 우 — 낙찰 선정 패널 */}
    - -
    + + )} {toast}
    ); } -function BoothCard({ booth, onAction }: { booth: AwardedBooth; onAction: (label: string) => void }) { +function BoothCard({ booth, onOpen }: { booth: AwardedBooth; onOpen: () => void }) { return ( -
    +
    - {booth.eventTag} + {booth.event}

    {booth.name}

    - Booth {booth.booth} - - - {booth.client} + Booth {booth.booth || '—'} + {booth.client && ( + + {booth.client} + + )}
    - {booth.dday != null ? ( - <> -

    남은 기간

    -
    - -
    - - ) : ( - <> -

    상태

    -

    - {booth.statusText} -

    - - )} +

    상태

    +

    + {booth.stage} +

    - - {booth.variant === 'primary' ? ( - <> -
    - {STEPS.map((label, i) => { - const state = - i < booth.stepIndex ? 'done' : i === booth.stepIndex ? 'current' : 'todo'; - return ( -
    - {i + 1} - {label} -
    - ); - })} -
    -
    - - - -
    - - ) : ( -
    - -
    - )} +
    + +
    ); } -function BidItem({ bid }: { bid: BidRow }) { - const rankCls = bid.rank === 1 ? 'kx-rankpill--1' : bid.rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low'; +function BidItem({ bid, onOpen }: { bid: MyBid; onOpen: () => void }) { + const rank = bid.myRank ?? 0; + const rankCls = rank === 1 ? 'kx-rankpill--1' : rank > 0 && rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low'; return ( -
    +
    + ); } diff --git a/src/frontend/src/screens/visitor/CheckinDeskPage.tsx b/src/frontend/src/screens/visitor/CheckinDeskPage.tsx new file mode 100644 index 0000000..3caf532 --- /dev/null +++ b/src/frontend/src/screens/visitor/CheckinDeskPage.tsx @@ -0,0 +1,395 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Button } from '../../components/ui/Button'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { IconSearch, IconWifiOff, IconCheckCircle, IconGrid, IconUsers } from '../../components/ui/icons'; +import { ApiRequestError } from '../../api/client'; +import { useResolvedEventId } from '../../hooks/useResolvedEventId'; +import { CHART } from '../chartColors'; +import { REG_TYPE_LABEL, type RegVisitorType } from './sampleVisitor'; +import { + visitorApi, + type CheckinResultDto, + type CheckinSearchItemDto, + type CheckinStatsDto, +} from './visitorApi'; +import './visitor.css'; +import './checkin.css'; + +/* + * SCR-31 현장 체크인 데스크 / 키오스크 (M10). Stitch scr_31_checkin_desk 이식. + * 정상 경로: POST /api/events/{eventId}/checkin (토큰/등록번호), GET /checkin/search, GET /checkin/stats, + * POST /visitors/{id}/badge/reissue. 3상태(로딩/빈/에러) 준수. + * ★ 보안 불변: 관람객 이름·연락처는 서버 마스킹 필드만 소비(원문 미노출, N2/R10). + * ★ 카메라 대신 토큰 입력 + 수동 검색으로 실동작(요청 사양). 오프라인 폴백 배지 표기(F049). + */ +export function CheckinDeskPage() { + const eventId = useResolvedEventId(); + const qc = useQueryClient(); + const [kiosk, setKiosk] = useState(false); + const [token, setToken] = useState(''); + const [search, setSearch] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [searchError, setSearchError] = useState(null); + const [card, setCard] = useState(null); + const [scanError, setScanError] = useState(null); + + const statsQ = useQuery({ + queryKey: ['checkin-stats', eventId], + queryFn: () => visitorApi.checkinStats(eventId as string), + enabled: !!eventId, + retry: false, + refetchInterval: 15000, + }); + + const degraded = isDegradable(statsQ.error); + const stats: CheckinStatsDto | null = statsQ.data ?? (degraded ? SAMPLE_STATS : null); + const hardError = statsQ.isError && !degraded; + + const checkinMut = useMutation({ + mutationFn: (body: { token?: string; registrationId?: string }) => + visitorApi.checkin(eventId as string, body), + onSuccess: (res) => { + setCard(res); + setScanError(null); + setToken(''); + setSearchResults(null); + setSearch(''); + qc.invalidateQueries({ queryKey: ['checkin-stats', eventId] }); + }, + onError: (e) => { + setCard(null); + setScanError( + e instanceof ApiRequestError && e.code === 'NOT_FOUND' + ? '등록 정보를 찾을 수 없습니다 — 현장 등록이 필요합니다.' + : '체크인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.', + ); + }, + }); + + const reissueMut = useMutation({ + mutationFn: (registrationId: string) => visitorApi.reissueBadge(eventId as string, registrationId), + onSuccess: (res) => + setCard((c) => (c ? { ...c, badgeCode: res.badgeCode, badgeIssued: true } : c)), + }); + + const runSearch = async () => { + if (!eventId || search.trim().length < 2) { + setSearchError('검색어는 2자 이상 입력해 주세요.'); + return; + } + setSearchError(null); + try { + const rows = await visitorApi.checkinSearch(eventId, search.trim()); + setSearchResults(rows); + } catch (e) { + setSearchResults([]); + setSearchError( + e instanceof ApiRequestError && e.code === 'NETWORK' + ? '네트워크 연결을 확인해 주세요.' + : '검색 중 오류가 발생했습니다.', + ); + } + }; + + const submitToken = () => { + if (!token.trim()) { + setScanError('QR 토큰(배지 코드)을 입력해 주세요.'); + return; + } + checkinMut.mutate({ token: token.trim() }); + }; + + const chartData = useMemo( + () => (stats?.byHour ?? []).map((b) => ({ hour: b.hour, count: b.count })), + [stats], + ); + + if (!eventId) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +
    +

    현장 체크인 데스크

    +

    배지 QR 스캔 · 수동 검색 · 실시간 입장 집계

    +
    +
    + {degraded && ( + + 오프라인 — 로컬 저장 후 동기화 + + )} + +
    +
    + +
    + {/* 좌: 스캔 존 + 수동 검색 */} +
    + + +
    { + e.preventDefault(); + submitToken(); + }} + > + +
    + setToken(e.target.value)} + placeholder="예: QR-XXXX 또는 KTX-V-0001" + autoComplete="off" + /> + +
    +
    + +
    또는 수동 검색
    + +
    + +
    + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(); + } + }} + placeholder="검색어 2자 이상" + autoComplete="off" + /> + +
    + {searchError &&

    {searchError}

    } + + {searchResults && searchResults.length > 0 && ( +
      + {searchResults.map((r) => ( +
    • + +
      + + {r.nameMasked} + + + {r.company ?? '-'} · {r.phoneMasked ?? '-'} · {r.badgeCode ?? '-'} + +
      + {r.checkinState === 'done' ? ( + 완료 + ) : ( + + )} +
    • + ))} +
    + )} + {searchResults && searchResults.length === 0 && !searchError && ( +

    검색 결과가 없습니다 — 현장 등록을 진행해 주세요.

    + )} +
    +
    + + {/* 중: 스캔 결과 카드 */} +
    + {scanError && ( +
    +

    {scanError}

    + +
    + )} + + {!card && !scanError && ( +
    +
    + )} + + {card && ( +
    +
    + {card.alreadyCheckedIn ? ( + 이미 체크인됨 + ) : ( + 체크인 완료 + )} +
    + +

    {card.nameMasked}

    +

    + · {card.company ?? '-'} +

    +
    +
    배지 번호
    {card.badgeCode ?? '-'}
    +
    체크인 시각
    {card.checkinAt ?? '-'}
    +
    +
    + + +
    +

    개인정보 보호 · 이름 마스킹 표기(원문 미표시)

    +
    + )} +
    + + {/* 우: 실시간 입장 집계 */} + +
    +
    + ); +} + +const TOOLTIP_STYLE = { + fontSize: 12, + borderRadius: 8, + border: '1px solid #E4E7EC', + boxShadow: '0 4px 12px rgba(16,24,40,0.1)', +} as const; + +const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip']; +function TypePill({ type }: { type: string }) { + const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor'; + return {REG_TYPE_LABEL[t]}; +} + +/** 폴백 집계(오프라인 강등 시). */ +const SAMPLE_STATS: CheckinStatsDto = { + preRegistered: 12480, + checkedIn: 8210, + inside: 7553, + byHour: [ + { hour: '09시', count: 1240 }, + { hour: '10시', count: 2180 }, + { hour: '11시', count: 1760 }, + { hour: '12시', count: 980 }, + { hour: '13시', count: 1150 }, + { hour: '14시', count: 900 }, + ], +}; + +function isDegradable(error: unknown): boolean { + return ( + error instanceof ApiRequestError && + (error.code === 'NETWORK' || error.code === 'NOT_FOUND' || error.code === 'NOT_IMPLEMENTED') + ); +} + +/** 스캔 프레임 글리프(선 SVG). */ +function ScanGlyph() { + return ( + + ); +} + +/** QR 글리프(선 SVG, 장식). */ +function QrGlyph({ size = 40 }: { size?: number }) { + return ( + + ); +} diff --git a/src/frontend/src/screens/visitor/LeadScoringPage.tsx b/src/frontend/src/screens/visitor/LeadScoringPage.tsx index 716956a..ac50c1c 100644 --- a/src/frontend/src/screens/visitor/LeadScoringPage.tsx +++ b/src/frontend/src/screens/visitor/LeadScoringPage.tsx @@ -22,6 +22,21 @@ export function LeadScoringPage() { const { boothId } = useParams(); const [hotOnly, setHotOnly] = useState(false); const [selectedId, setSelectedId] = useState(''); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + + const handleExport = async () => { + if (!eventId || exporting) return; + setExporting(true); + setExportError(null); + try { + await visitorApi.exportLeadsCsv(eventId, boothId ?? null); + } catch { + setExportError('CSV 내보내기에 실패했습니다. 잠시 후 다시 시도해 주세요.'); + } finally { + setExporting(false); + } + }; const q = useQuery({ queryKey: ['leads', eventId, boothId ?? null], @@ -60,8 +75,16 @@ export function LeadScoringPage() { {degraded && ( 오프라인 — 샘플 )} -
    @@ -169,7 +192,7 @@ export function LeadScoringPage() { {/* 리드 상세·팔로업 */} - {selected && } + {selected && }
    )} @@ -177,7 +200,7 @@ export function LeadScoringPage() { ); } -function LeadDetail({ lead }: { lead: LeadItemDto }) { +function LeadDetail({ lead, onExport, exporting }: { lead: LeadItemDto; onExport: () => void; exporting: boolean }) { const hot = lead.score >= 80; return (