feat(domains): second-wave hardening — auction scoring, visitor check-in/lead engine, CMS versions/media/public (V21)

- 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 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 09:09:14 +09:00
parent 2854f068ed
commit c6f3a8d916
41 changed files with 3080 additions and 514 deletions

View File

@ -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 | **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 | **실데이터·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 | **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 경유) | | 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 경유) |

View File

@ -64,6 +64,15 @@ public class AuctionController {
return ApiResponse.ok(service.placeBid(id, principal, req)); return ApiResponse.ok(service.placeBid(id, principal, req));
} }
/** POST /api/auctions/{id}/close — 라운드 마감 처리(발주자만). 마감 후 봉인 해제·응찰 차단. */
@Audited(action = "AUCTION_CLOSE", targetType = "auction")
@PostMapping("/{id}/close")
public ApiResponse<AuctionSummary> close(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.closeRound(id, principal));
}
/** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */ /** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */
@Audited(action = "AUCTION_AWARD", targetType = "award") @Audited(action = "AUCTION_AWARD", targetType = "award")
@PostMapping("/{id}/award") @PostMapping("/{id}/award")

View File

@ -151,6 +151,10 @@ public interface AuctionMapper {
@Select("SELECT auction_id FROM bid WHERE id = #{bidId}") @Select("SELECT auction_id FROM bid WHERE id = #{bidId}")
String findAuctionIdOfBid(@Param("bidId") String 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("""
INSERT INTO award (id, auction_id, bid_id, reason, awarded_by) INSERT INTO award (id, auction_id, bid_id, reason, awarded_by)
VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy}) VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy})

View File

@ -141,14 +141,13 @@ public class AuctionService {
throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 미초대 차단 throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 미초대 차단
} }
// 라인아이템이 있으면 서버가 소계 재계산(신뢰 경계). 없으면 total(부가세 별도) 폴백. // 서버 재계산(신뢰 경계): 라인아이템이 있으면 소계=Σ(수량×단가), 없으면 total 폴백.
long subtotal = hasLines // 음수 수량·단가, 소계 0 이하는 QuotationCalc 검증(400). 산식은 QuotationCalcTest 고정.
? req.lines().stream().mapToLong(l -> nz(l.qty()) * nz(l.unitPrice())).sum() QuotationCalc.Totals t = hasLines
: req.total(); ? QuotationCalc.fromLines(req.lines())
if (subtotal <= 0) { : QuotationCalc.fromTotal(req.total());
throw new ApiException(ErrorCode.VALIDATION); long subtotal = t.subtotal();
} long vat = t.vat();
long vat = Math.round(subtotal * 0.1);
long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계 long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계
int leadDays = req.leadDays() == null ? 0 : req.leadDays(); int leadDays = req.leadDays() == null ? 0 : req.leadDays();
String linesJson = hasLines ? toJson(req.lines()) : null; String linesJson = hasLines ? toJson(req.lines()) : null;
@ -182,7 +181,29 @@ public class AuctionService {
int myRank = mapper.lowerBidCount(id, total) + 1; int myRank = mapper.lowerBidCount(id, total) + 1;
Long lowest = lng(mapper.findAuction(id).get("lowestPrice")); Long lowest = lng(mapper.findAuction(id).get("lowestPrice"));
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<String, Object> 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) // 낙찰(Award)
@ -254,17 +275,8 @@ public class AuctionService {
long total = lngPrim(r.get("total")); long total = lngPrim(r.get("total"));
int lead = intOr(r.get("leadDays"), 0); int lead = intOr(r.get("leadDays"), 0);
double rating = dbl(r.get("rating"), 4.0); double rating = dbl(r.get("rating"), 4.0);
double priceScore = minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0; // 종합점수 산식은 AuctionScoring(순수 함수) 위임 AuctionScoringTest 고정.
double repScore = rating / 5.0 * 100.0; scores[i] = AuctionScoring.score(criteria, w, total, minTotal, lead, minLead, rating);
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);
if (scores[i] > bestScore) { if (scores[i] > bestScore) {
bestScore = scores[i]; bestScore = scores[i];
bestIdx = i; bestIdx = i;
@ -526,4 +538,15 @@ public class AuctionService {
private static double round1(double v) { private static double round1(double v) {
return Math.round(v * 10.0) / 10.0; 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; // 직렬화 실패 라인아이템 스냅샷 생략(집계는 컬럼값 사용)
}
}
} }

View File

@ -4,10 +4,13 @@ import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest;
import com.zioinfo.kintex.cms.dto.CmsContentDto; 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.CmsTranslationDto;
import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest; import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse; import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.Audited;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -18,8 +21,10 @@ import java.util.List;
* M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수. * M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수.
* <ul> * <ul>
* <li>GET/POST /api/cms/contents 목록/신규(초안)</li> * <li>GET/POST /api/cms/contents 목록/신규(초안)</li>
* <li>GET/PUT /api/cms/contents/&#123;id&#125; 상세/본문 저장(sanitize·버전 스냅샷)</li>
* <li>PATCH /api/cms/contents/&#123;id&#125;/status?value= 게시 전이(전진만·역전이 400, 승인/게시는 매니저)</li> * <li>PATCH /api/cms/contents/&#123;id&#125;/status?value= 게시 전이(전진만·역전이 400, 승인/게시는 매니저)</li>
* <li>GET/PUT /api/cms/contents/&#123;id&#125;/translations 언어별 번역 upsert(ko/en/zh/ja)</li> * <li>GET /api/cms/contents/&#123;id&#125;/versions · POST /rollback?versionNo= 버전 이력/롤백(매니저)</li>
* <li>GET/PUT /api/cms/contents/&#123;id&#125;/translations · POST /translations/ai?lang= 번역 upsert / AI 초벌(501)</li>
* </ul> * </ul>
*/ */
@RestController @RestController
@ -54,7 +59,25 @@ public class CmsContentController {
return ApiResponse.ok(service.create(principal, req)); return ApiResponse.ok(service.create(principal, req));
} }
@GetMapping("/{id}")
public ApiResponse<CmsContentDto> 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<CmsContentDto> 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. */ /** 게시 상태 전이. value=draft|review|approved|published(전진만). 역전이/무효 → 400. */
@Audited(action = "CMS_CONTENT_TRANSITION", targetType = "cms_content")
@PatchMapping("/{id}/status") @PatchMapping("/{id}/status")
public ApiResponse<CmsContentDto> transition(@AuthenticationPrincipal KintexPrincipal principal, public ApiResponse<CmsContentDto> transition(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id, @PathVariable String id,
@ -63,6 +86,25 @@ public class CmsContentController {
return ApiResponse.ok(service.transition(principal, id, value)); return ApiResponse.ok(service.transition(principal, id, value));
} }
/** 버전 이력(최신순). */
@GetMapping("/{id}/versions")
public ApiResponse<List<CmsContentVersionDto>> 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<CmsContentDto> rollback(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestParam int versionNo) {
guard.require(principal);
return ApiResponse.ok(service.rollback(principal, id, versionNo));
}
@GetMapping("/{id}/translations") @GetMapping("/{id}/translations")
public ApiResponse<List<CmsTranslationDto>> translations( public ApiResponse<List<CmsTranslationDto>> translations(
@AuthenticationPrincipal KintexPrincipal principal, @AuthenticationPrincipal KintexPrincipal principal,
@ -79,4 +121,17 @@ public class CmsContentController {
guard.require(principal); guard.require(principal);
return ApiResponse.ok(service.saveTranslation(id, req)); return ApiResponse.ok(service.saveTranslation(id, req));
} }
/**
* AI 자동 번역(초벌) 인터페이스만. AiTextRouter 미배선 501 NOT_IMPLEMENTED.
* 프론트(SCR-37) 501을 degraded("준비 중") 처리한다.
*/
@PostMapping("/{id}/translations/ai")
public ApiResponse<CmsTranslationDto> aiTranslate(
@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestParam String lang) {
guard.require(principal);
return ApiResponse.ok(service.aiTranslate(id, lang));
}
} }

View File

@ -75,6 +75,107 @@ public interface CmsMapper {
""") """)
int updateStatus(@Param("id") String id, @Param("status") String status); 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<String, Object> p);
/** 롤백 — 특정 버전의 제목/본문/상태로 복원(published 는 published_at 유지). */
@Update("""
UPDATE cms_content
SET title = #{title},
body = #{body},
status = #{status},
updated_at = now()
WHERE id = #{id}
""")
int restoreContent(Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> p);
@Select("""
<script>
SELECT id, event_id AS "eventId", file_name AS "fileName", url, content_type AS "contentType",
size_bytes AS "sizeBytes", alt_text AS "altText", uploader_name AS "uploaderName",
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
FROM cms_media
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
</where>
ORDER BY created_at DESC
LIMIT #{limit}
</script>
""")
List<Map<String, Object>> findMedia(Map<String, Object> q);
// 공개 조회(게시 상태만)
@Select("""
<script>
SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang,
to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt",
to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt"
FROM cms_content
WHERE status = 'published' AND content_type = #{type}
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
ORDER BY published_at DESC NULLS LAST, updated_at DESC
LIMIT #{limit}
</script>
""")
List<Map<String, Object>> findPublishedByType(Map<String, Object> q);
// 번역 // 번역
@Select(""" @Select("""
SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus", SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus",

View File

@ -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/** 서빙.
* <ul>
* <li>GET /api/cms/media?eventId=&limit= 최신순 목록</li>
* <li>POST /api/cms/media (multipart: file[+eventId,altText]) 업로드</li>
* </ul>
*/
@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<CmsMediaDto>> 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<CmsMediaDto> 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));
}
}

View File

@ -1,41 +1,61 @@
package com.zioinfo.kintex.cms; package com.zioinfo.kintex.cms;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; import com.zioinfo.kintex.cms.dto.*;
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.common.PageResponse; import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.common.text.HtmlSanitizer;
import com.zioinfo.kintex.system.SystemAccessGuard; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.*; import java.util.*;
/** /**
* M17 CMS 서비스 콘텐츠 게시 워크플로 + 다국어 번역. * M17 CMS 서비스 콘텐츠 게시 워크플로 + 버전 이력/롤백 + 미디어 + 다국어 + 예약 게시 + 공개 조회.
* <p>상태 전이: draft(0)review(1)approved(2)published(3) 전진만 허용. 역전이/동일 전이 400(VALIDATION). * <p>상태 전이: draft(0)review(1)approved(2)published(3) 전진만 허용. 역전이/동일 전이 400(VALIDATION).
* approved·published 전이는 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합). * approved·published 전이와 롤백은 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합).
* <p>본문 저장 {@link HtmlSanitizer} XSS 방어(N2). AI 자동 번역은 인터페이스만(501, AiTextRouter 배선 대기).
*/ */
@Service @Service
public class CmsService { public class CmsService {
private static final Logger log = LoggerFactory.getLogger(CmsService.class);
private static final List<String> FLOW = List.of("draft", "review", "approved", "published"); private static final List<String> FLOW = List.of("draft", "review", "approved", "published");
private static final Set<String> TRANS_STATUS = Set.of("none", "ai", "reviewed"); private static final Set<String> TRANS_STATUS = Set.of("none", "ai", "reviewed");
private static final Set<String> LANGS = Set.of("ko", "en", "zh", "ja"); private static final Set<String> LANGS = Set.of("ko", "en", "zh", "ja");
private static final long MAX_MEDIA_BYTES = 8L * 1024 * 1024; // 8MB
private static final Map<String, String> ALLOWED_TYPES = Map.of(
"image/png", "png", "image/jpeg", "jpg", "image/webp", "webp", "image/gif", "gif");
private static final Set<String> ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp", "gif");
private final CmsMapper mapper; private final CmsMapper mapper;
private final SystemAccessGuard scope; 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.mapper = mapper;
this.scope = scope; this.scope = scope;
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
} }
// 콘텐츠 목록/조회
public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword, public PageResponse<CmsContentDto> list(String eventId, String status, String type, String keyword,
int page, int size) { int page, int size) {
runScheduledPublish();
int p = Math.max(page, 0); int p = Math.max(page, 0);
int s = size <= 0 ? 50 : Math.min(size, 200); int s = size <= 0 ? 50 : Math.min(size, 200);
Map<String, Object> q = new HashMap<>(); Map<String, Object> q = new HashMap<>();
@ -66,11 +86,33 @@ public class CmsService {
pm.put("eventId", blankToNull(req.eventId())); pm.put("eventId", blankToNull(req.eventId()));
pm.put("contentType", blankToNull(req.contentType())); pm.put("contentType", blankToNull(req.contentType()));
pm.put("title", req.title()); 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("lang", blankToNull(req.lang()));
pm.put("authorId", principal.userId()); pm.put("authorId", principal.userId());
pm.put("authorName", principal.displayName()); pm.put("authorName", principal.displayName());
mapper.insertContent(pm); 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<String, Object> 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); return get(id);
} }
@ -97,9 +139,60 @@ public class CmsService {
scope.requireManager(principal); scope.requireManager(principal);
} }
mapper.updateStatus(id, to); mapper.updateStatus(id, to);
if ("published".equals(to)) {
snapshot(id, "published", principal.displayName());
}
return get(id); return get(id);
} }
// 버전 이력/롤백
public List<CmsContentVersionDto> 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<String, Object> v = mapper.findVersion(contentId, versionNo);
if (v == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "해당 버전을 찾을 수 없습니다: v" + versionNo);
}
Map<String, Object> 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<String, Object> cur = mapper.findContentById(contentId);
if (cur == null) {
return;
}
int next = mapper.maxVersionNo(contentId) + 1;
Map<String, Object> 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<CmsTranslationDto> translations(String contentId) { public List<CmsTranslationDto> translations(String contentId) {
if (mapper.findContentById(contentId) == null) { if (mapper.findContentById(contentId) == null) {
@ -126,12 +219,98 @@ public class CmsService {
pm.put("contentId", contentId); pm.put("contentId", contentId);
pm.put("lang", lang); pm.put("lang", lang);
pm.put("title", req.title()); pm.put("title", req.title());
pm.put("body", req.body()); pm.put("body", HtmlSanitizer.sanitize(req.body()));
pm.put("transStatus", st); pm.put("transStatus", st);
mapper.upsertTranslation(pm); mapper.upsertTranslation(pm);
return translations(contentId); return translations(contentId);
} }
/**
* AI 자동 번역 인터페이스만(AiTextRouter/Claude 배선 지점). 미설정 501 NOT_IMPLEMENTED.
* <p>프론트는 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<CmsMediaDto> mediaList(String eventId, int limit) {
Map<String, Object> 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<String, Object> 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<CmsContentDto> publicByType(String type, String eventId, int limit) {
runScheduledPublish();
Map<String, Object> q = new HashMap<>();
q.put("type", type == null ? "PAGE" : type.trim().toUpperCase(Locale.ROOT));
q.put("eventId", blankToNull(eventId));
q.put("limit", limit <= 0 ? 50 : Math.min(limit, 200));
return mapper.findPublishedByType(q).stream().map(CmsService::toPublicDto).toList();
}
// 매핑 // 매핑
private static CmsContentDto toContentDto(Map<String, Object> r) { private static CmsContentDto toContentDto(Map<String, Object> r) {
return new CmsContentDto( return new CmsContentDto(
@ -142,12 +321,62 @@ public class CmsService {
str(r.get("createdAt")), str(r.get("updatedAt"))); str(r.get("createdAt")), str(r.get("updatedAt")));
} }
/** 공개 응답 — 저자/예약/플래그 등 운영 필드는 노출하지 않는다. */
private static CmsContentDto toPublicDto(Map<String, Object> 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<String, Object> r) { private static CmsTranslationDto toTranslationDto(Map<String, Object> r) {
return new CmsTranslationDto( return new CmsTranslationDto(
str(r.get("contentId")), str(r.get("lang")), str(r.get("title")), str(r.get("contentId")), str(r.get("lang")), str(r.get("title")),
str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt"))); str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt")));
} }
private static CmsContentVersionDto toVersionDto(Map<String, Object> 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<String, Object> 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) { private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s; return s == null || s.isBlank() ? null : s;
} }

View File

@ -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) 상태만 노출.
* 조회 예약 시각 도달분을 자동 게시 처리한다(지연 스윕). 운영 필드(저자·예약·플래그) 제외.
* <ul>
* <li>GET /api/public/cms/{type}?eventId=&limit= type: PAGE|POST|NOTICE|BLOCK</li>
* </ul>
*/
@RestController
@RequestMapping("/api/public/cms")
public class PublicCmsController {
private final CmsService service;
public PublicCmsController(CmsService service) {
this.service = service;
}
@GetMapping("/{type}")
public ApiResponse<List<CmsContentDto>> byType(@PathVariable String type,
@RequestParam(required = false) String eventId,
@RequestParam(defaultValue = "50") int limit) {
return ApiResponse.ok(service.publicByType(type, eventId, limit));
}
}

View File

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

View File

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

View File

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

View File

@ -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.
* <p>외부 의존성(jsoup ) 없이 순수 정규식 전략으로 위험 요소를 제거한다.
* <ul>
* <li>{@code <script>}·{@code <style>}·{@code <iframe>}·{@code <object>}·{@code <embed>} 위험 태그는 내용째 제거.</li>
* <li>{@code on*} 인라인 이벤트 핸들러 속성 제거(onclick·onerror·onload ).</li>
* <li>{@code javascript:}·{@code data:}·{@code vbscript:} 스킴 href/src 제거.</li>
* <li>화이트리스트에 없는 태그는 태그만 제거하고 <b>텍스트 내용은 보존</b>(escape 아님).</li>
* </ul>
* 에디터는 블록/리치텍스트(제한 서식)이므로 본문은 서식 태그 소집합만 허용한다.
*/
public final class HtmlSanitizer {
/** 허용 태그(여닫기 공통) — 리치텍스트 최소 서식. */
private static final Set<String> ALLOWED = Set.of(
"p", "br", "b", "strong", "i", "em", "u", "s",
"ul", "ol", "li", "blockquote", "code", "pre",
"h1", "h2", "h3", "h4", "a", "span", "div");
/** 내용째 삭제할 위험 태그(여는 태그 ~ 닫는 태그 전체). */
private static final Pattern DANGEROUS_BLOCKS = Pattern.compile(
"(?is)<\\s*(script|style|iframe|object|embed|form|svg|math|template|noscript)\\b.*?<\\s*/\\s*\\1\\s*>");
/** 짝이 맞지 않는 위험 여는/닫는 태그 잔재. */
private static final Pattern DANGEROUS_SOLO = Pattern.compile(
"(?is)<\\s*/?\\s*(script|style|iframe|object|embed|form|svg|math|template|noscript)\\b[^>]*>");
/** on* 인라인 이벤트 핸들러 속성. */
private static final Pattern EVENT_ATTR = Pattern.compile(
"(?is)\\s+on[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
/** 위험 스킴 URL 속성(href/src/xlink:href). */
private static final Pattern DANGEROUS_URL = Pattern.compile(
"(?is)\\s+(href|src|xlink:href)\\s*=\\s*(\"\\s*(javascript|data|vbscript):[^\"]*\"|'\\s*(javascript|data|vbscript):[^']*'|(javascript|data|vbscript):[^\\s>]+)");
/** 일반 태그 매칭(여는/닫는). */
private static final Pattern ANY_TAG = Pattern.compile("(?is)<\\s*(/?)\\s*([a-z][a-z0-9]*)\\b[^>]*>");
private HtmlSanitizer() {
}
/**
* 본문을 안전하게 정화한다. null/blank 그대로 반환.
* 최대 길이(200,000자) 초과하면 잘라낸다(폭주 방어).
*/
public static String sanitize(String raw) {
if (raw == null || raw.isEmpty()) {
return raw;
}
String s = raw.length() > 200_000 ? raw.substring(0, 200_000) : raw;
// 1) 위험 블록/잔재 태그 제거(내용째).
s = DANGEROUS_BLOCKS.matcher(s).replaceAll("");
s = DANGEROUS_SOLO.matcher(s).replaceAll("");
// 2) 이벤트 핸들러·위험 스킴 속성 제거.
s = EVENT_ATTR.matcher(s).replaceAll("");
s = DANGEROUS_URL.matcher(s).replaceAll("");
// 3) 화이트리스트에 없는 태그는 태그만 제거(텍스트 보존).
Matcher m = ANY_TAG.matcher(s);
StringBuilder out = new StringBuilder(s.length());
while (m.find()) {
String tag = m.group(2).toLowerCase();
m.appendReplacement(out, ALLOWED.contains(tag) ? Matcher.quoteReplacement(m.group()) : "");
}
m.appendTail(out);
return out.toString();
}
}

View File

@ -1,14 +1,21 @@
package com.zioinfo.kintex.visitor; package com.zioinfo.kintex.visitor;
import com.zioinfo.kintex.auth.EventAccessGuard; import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.EventRole;
import com.zioinfo.kintex.auth.KintexPrincipal; import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.PageResponse; import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.audit.Audited; import com.zioinfo.kintex.common.audit.Audited;
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*; import com.zioinfo.kintex.visitor.dto.VisitorDtos.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
/** /**
* M10 관람객·리드 API (SCR-30·SCR-32) + 공개 사전등록 접수. * M10 관람객·리드 API (SCR-30·SCR-32) + 공개 사전등록 접수.
* <ul> * <ul>
@ -63,4 +70,74 @@ public class VisitorController {
guard.requireEventAccess(principal, eventId); guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.leads(eventId, boothId, page, size)); return ApiResponse.ok(service.leads(eventId, boothId, page, size));
} }
// SCR-31 현장 체크인
/** QR 토큰/등록번호 체크인(중복 방지). 홀매니저·주최자 현장 운영 역할. */
@Audited(action = "VISITOR_CHECKIN", targetType = "visitor_registration")
@PostMapping("/api/events/{eventId}/checkin")
public ApiResponse<CheckinResult> checkin(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestBody CheckinRequest req) {
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
return ApiResponse.ok(service.checkin(eventId, req));
}
/** 현장 수동 검색(마스킹 결과). */
@GetMapping("/api/events/{eventId}/checkin/search")
public ApiResponse<List<CheckinSearchItem>> checkinSearch(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam("q") String q) {
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
return ApiResponse.ok(service.searchForCheckin(eventId, q));
}
/** 실시간 입장 집계(시간대별·장내 추정). */
@GetMapping("/api/events/{eventId}/checkin/stats")
public ApiResponse<CheckinStats> checkinStats(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.checkinStats(eventId));
}
/** 배지 재발급. */
@Audited(action = "VISITOR_BADGE_REISSUE", targetType = "visitor_registration")
@PostMapping("/api/events/{eventId}/visitors/{registrationId}/badge/reissue")
public ApiResponse<BadgeReissueResult> reissueBadge(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String registrationId) {
guard.requireRole(principal, eventId, EventRole.HALL_MANAGER, EventRole.ORGANIZER);
return ApiResponse.ok(service.reissueBadge(eventId, registrationId));
}
// SCR-32 리드 수집·내보내기
/** 리드 수집(참가업체 부스 스캔) — 서버 규칙 엔진이 스코어 산출. */
@Audited(action = "LEAD_COLLECT", targetType = "lead")
@PostMapping("/api/events/{eventId}/leads")
public ApiResponse<LeadCollectResult> collectLead(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestBody LeadCollectRequest req) {
guard.requireRole(principal, eventId, EventRole.EXHIBITOR, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
return ApiResponse.ok(service.collectLead(eventId, req));
}
/** 리드 CSV 내보내기(마스킹 필드만). UTF-8 BOM 포함(엑셀 한글 호환). */
@Audited(action = "LEAD_EXPORT_CSV", targetType = "lead")
@GetMapping("/api/events/{eventId}/leads/export")
public ResponseEntity<byte[]> exportLeadsCsv(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@RequestParam(required = false) String boothId) {
guard.requireRole(principal, eventId, EventRole.EXHIBITOR, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
String csv = service.leadsCsv(eventId, boothId);
byte[] bom = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
byte[] body = csv.getBytes(StandardCharsets.UTF_8);
byte[] out = new byte[bom.length + body.length];
System.arraycopy(bom, 0, out, 0, bom.length);
System.arraycopy(body, 0, out, bom.length, body.length);
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"leads-" + eventId + ".csv\"")
.body(out);
}
} }

View File

@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -161,4 +162,138 @@ public interface VisitorMapper {
@Param("agreePrivacy") boolean agreePrivacy, @Param("agreePrivacy") boolean agreePrivacy,
@Param("agreeMarketing") boolean agreeMarketing, @Param("agreeMarketing") boolean agreeMarketing,
@Param("badgeCode") String badgeCode); @Param("badgeCode") String badgeCode);
// 현장 체크인(SCR-31)
/** QR 토큰 또는 등록ID 로 등록 1건 해소 — 체크인 판정에 필요한 상태·마스킹 필드만 반환(원문 미반환). */
@Select("""
<script>
SELECT id AS "registrationId",
CASE WHEN char_length(name) &lt;= 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",
badge_code AS "badgeCode",
badge_issued AS "badgeIssued",
checkin_state AS "checkinState",
to_char(checkin_at, 'YYYY.MM.DD HH24:MI') AS "checkinAt"
FROM visitor_registration
WHERE event_id = #{eventId}
<choose>
<when test="token != null and token != ''">AND qr_token = #{token}</when>
<otherwise>AND id = #{registrationId}</otherwise>
</choose>
LIMIT 1
</script>
""")
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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("""
<script>
SELECT CASE WHEN char_length(name) &lt;= 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",
role, company, product, interest, score,
CASE WHEN phone IS NULL THEN NULL
WHEN char_length(regexp_replace(phone,'\\D','','g')) &gt;= 7
THEN left(regexp_replace(phone,'\\D','','g'),3) || '-****-' || right(regexp_replace(phone,'\\D','','g'),4)
ELSE '***' END AS "phoneMasked",
CASE WHEN email IS NULL OR position('@' in email) = 0 THEN NULL
ELSE left(split_part(email,'@',1),3) || '***@' || split_part(email,'@',2)
END AS "emailMasked",
to_char(collected_at, 'YYYY-MM-DD HH24:MI:SS') AS "collectedAt"
FROM lead
WHERE event_id = #{eventId}
<if test="boothId != null and boothId != ''">AND booth_id = #{boothId}</if>
ORDER BY score DESC, collected_at DESC
</script>
""")
List<Map<String, Object>> findLeadsForCsv(@Param("eventId") String eventId, @Param("boothId") String boothId);
} }

View File

@ -5,6 +5,9 @@ import com.zioinfo.kintex.common.PageResponse;
import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.visitor.dto.VisitorDtos.*; 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 org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
@ -29,9 +32,11 @@ public class VisitorService {
"vip", new String[]{"VIP", "#0E8A5F"}); "vip", new String[]{"VIP", "#0E8A5F"});
private final VisitorMapper mapper; private final VisitorMapper mapper;
private final LeadScorer leadScorer;
public VisitorService(VisitorMapper mapper) { public VisitorService(VisitorMapper mapper, LeadScorer leadScorer) {
this.mapper = mapper; this.mapper = mapper;
this.leadScorer = leadScorer;
} }
// SCR-30 요약 // SCR-30 요약
@ -177,6 +182,145 @@ public class VisitorService {
return new RegisterResult(id, badgeCode); 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<String, Object> 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<String, Object> after = mapper.findForCheckin(eventId, null, rid);
Map<String, Object> 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<CheckinSearchItem> 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<Map<String, Object>> rows = mapper.searchRegistrations(eventId, query, digits);
List<CheckinSearchItem> out = new ArrayList<>(rows == null ? 0 : rows.size());
if (rows != null) {
for (Map<String, Object> 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<String, Object> c = orEmpty(mapper.findSummaryCounts(eventId));
long total = lng(c.get("total"));
long checkedIn = lng(c.get("checkedIn"));
List<Map<String, Object>> hours = mapper.findCheckinByHour(eventId);
List<HourBucket> byHour = new ArrayList<>();
if (hours != null) {
for (Map<String, Object> 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<Map<String, Object>> rows = mapper.findLeadsForCsv(eventId, bid);
StringBuilder sb = new StringBuilder();
sb.append("이름(마스킹),소속,직급,관심제품,관심도,AI스코어,연락처(마스킹),이메일(마스킹),수집시각\r\n");
if (rows != null) {
for (Map<String, Object> 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 // helpers
private static String normalizeType(String t) { private static String normalizeType(String t) {
if (t == null) return "visitor"; if (t == null) return "visitor";

View File

@ -49,4 +49,42 @@ public final class VisitorDtos {
public record RegisterResult(String registrationId, String badgeCode) { 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<HourBucket> 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<String> aiReasons, String scoreSource) {
}
} }

View File

@ -0,0 +1,11 @@
package com.zioinfo.kintex.visitor.scoring;
/**
* 리드 스코어링 전략(M10). 기본 구현은 규칙 엔진({@link RuleBasedLeadScorer}).
* <p>AI(Claude) 연동 지점: 별도 구현체가 규칙 스코어를 기저로 보정하도록 인터페이스만 두고,
* 실제 LLM 호출 구현은 후속 하네스(guardia-claude-ai)에서 주입한다. 현재는 규칙 엔진이 단일 빈이다.
*/
public interface LeadScorer {
ScoreResult score(LeadSignals signals);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.kintex.visitor.scoring;
/**
* 리드 스코어링 입력 신호(M10 SCR-32).
* <p>부스에서 배지 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);
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.kintex.visitor.scoring;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 규칙 기반 리드 스코어 엔진(M10 기본). 결정론적·검증 가능한 가중 합산.
* <p><b>산식(총점 100 = 관심도 40 + 체류 25 + 재방문 20 + 결정권자 10 + 자료열람 5):</b>
* <ul>
* <li>관심도(interest 1~5) × 8 8..40</li>
* <li>체류: (dwellRatioPct 100) × 0.25 [0,25] 클램프(평균 100 0, 200% 25)</li>
* <li>재방문: revisitCount × 8 [0,20] 클램프</li>
* <li>결정권자: 10 (해당 )</li>
* <li>자료열람: docDownloads × 5 [0,5] 클램프</li>
* </ul>
* 스코어는 [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<String> 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));
}
}

View File

@ -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<String> reasons, String source) {
}

View File

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

View File

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

View File

@ -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<QuoteLine> 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));
}
}

View File

@ -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<String, Object> contentRow(String status) {
Map<String, Object> 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(
"제목", "<p>안전</p><script>alert('xss')</script>", null, true, false);
service.update(editor, "cms-1", req);
ArgumentCaptor<Map<String, Object>> cap = ArgumentCaptor.forClass(Map.class);
verify(mapper).updateContent(cap.capture());
String storedBody = String.valueOf(cap.getValue().get("body"));
assertTrue(storedBody.contains("<p>안전</p>"), "허용 태그는 보존");
assertFalse(storedBody.toLowerCase().contains("<script"), "script 태그 제거");
assertFalse(storedBody.contains("alert"), "script 내용 제거");
verify(mapper).insertVersion(any()); // 저장 버전 스냅샷
}
@Test
void updateRejectsBlankTitle() {
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
var req = new CmsContentUpdateRequest(" ", "본문", null, null, null);
ApiException ex = assertThrows(ApiException.class, () -> service.update(editor, "cms-1", req));
assertEquals(ErrorCode.VALIDATION, ex.getCode());
}
// 롤백
@Test
void rollbackRestoresVersionAndSnapshots() {
when(mapper.findContentById("cms-1")).thenReturn(contentRow("published"));
Map<String, Object> 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<Map<String, Object>> 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());
}
}

View File

@ -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("<p>안녕</p><script>alert('xss')</script>");
assertTrue(out.contains("<p>안녕</p>"));
assertFalse(out.toLowerCase().contains("<script"));
assertFalse(out.contains("alert"));
}
@Test
void stripsInlineEventHandlers() {
String out = HtmlSanitizer.sanitize("<a href=\"/ok\" onclick=\"steal()\">링크</a>");
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("<a href=\"javascript:alert(1)\">x</a>");
assertFalse(out.toLowerCase().contains("javascript:"));
assertTrue(out.contains("x"));
}
@Test
void dropsDisallowedTagsButKeepsText() {
String out = HtmlSanitizer.sanitize("<marquee>흐름</marquee><iframe src=\"evil\"></iframe>");
assertFalse(out.toLowerCase().contains("<marquee"));
assertFalse(out.toLowerCase().contains("<iframe"));
assertTrue(out.contains("흐름"), "허용되지 않은 태그도 텍스트 내용은 보존");
}
@Test
void keepsAllowedFormattingTags() {
String in = "<h2>제목</h2><p>본문 <strong>강조</strong> <em>기울임</em></p><ul><li>항목</li></ul>";
String out = HtmlSanitizer.sanitize(in);
assertEquals(in, out, "허용 서식 태그는 그대로 유지");
}
@Test
void nullAndBlankPassThrough() {
assertNull(HtmlSanitizer.sanitize(null));
assertEquals("", HtmlSanitizer.sanitize(""));
}
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.kintex.visitor;
import com.zioinfo.kintex.visitor.scoring.LeadSignals;
import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer;
import com.zioinfo.kintex.visitor.scoring.ScoreResult;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* M10 리드 스코어 규칙 엔진 단위테스트 관심도·체류·재방문·결정권자·자료열람 가중 합산의 결정성 검증.
* 산식(총점 100 = 관심도 40 + 체류 25 + 재방문 20 + 결정권자 10 + 자료 5).
*/
class LeadScoringEngineTest {
private final RuleBasedLeadScorer scorer = new RuleBasedLeadScorer();
@Test
void hotLead_sumsWeightedSignals() {
// interest 5(=40) + dwell 240%clamp(round(140*0.25)=35,25)=25 + revisit 2clamp(16,20)=16
// + 결정권자 10 + 자료 2clamp(10,5)=5 = 96
ScoreResult r = scorer.score(new LeadSignals(5, 240, 2, true, 2));
assertEquals(96, r.score());
assertEquals("rule", r.source());
assertTrue(r.reasons().stream().anyMatch(s -> 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());
}
}

View File

@ -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<String, Object> row(String state, String checkinAt) {
Map<String, Object> 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);
}
}

View File

@ -40,6 +40,7 @@ import { AwardComparePage } from './screens/auction/AwardComparePage';
import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage'; import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage';
import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage'; import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage';
import { LeadScoringPage } from './screens/visitor/LeadScoringPage'; import { LeadScoringPage } from './screens/visitor/LeadScoringPage';
import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage';
import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage'; import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage';
import { SponsorshipPage } from './screens/marketing/SponsorshipPage'; import { SponsorshipPage } from './screens/marketing/SponsorshipPage';
import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage'; import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage';
@ -176,6 +177,7 @@ export function App() {
<Route path="/contractor/dashboard" element={<ContractorBoothDashboardPage />} /> <Route path="/contractor/dashboard" element={<ContractorBoothDashboardPage />} />
{/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */} {/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */}
<Route path="/visitors" element={<VisitorRegistrationDashboardPage />} /> <Route path="/visitors" element={<VisitorRegistrationDashboardPage />} />
<Route path="/visitors/checkin" element={<CheckinDeskPage />} />
<Route path="/leads" element={<LeadScoringPage />} /> <Route path="/leads" element={<LeadScoringPage />} />
<Route path="/campaigns" element={<EdmCampaignPage />} /> <Route path="/campaigns" element={<EdmCampaignPage />} />
<Route path="/sponsorship" element={<SponsorshipPage />} /> <Route path="/sponsorship" element={<SponsorshipPage />} />

View File

@ -42,6 +42,7 @@ const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>;
{ key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' }, { key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' },
{ key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' }, { key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' },
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' }, { key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
{ key: 'checkin', label: '체크인 데스크', Icon: IconCheckCircle, to: '/visitors/checkin' },
{ key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' }, { key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' },
{ key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' }, { key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' },
{ key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' }, { key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' },

View File

@ -1,39 +1,45 @@
/* /*
* SCR-27 · · [M15]. Stitch scr_27_auction_detail . * SCR-27 · · [M15]. API ().
* 대상: 장치업체(). AI (··BOQ·) + ·. * 정본: GET /api/auctions/{id} (AuctionDetail) · POST /{id}/bids · POST /{id}/close.
* M15 SAMPLE_RANKS . API/WebSocket ( ). * 대상: 장치업체()·. AI + ·.
* ( ): ( ) , * ( ): ranking / .
* "비공개" . "익명 순위" . * priceMasked/price ( ). refetchInterval 5.
* () + ("백엔드 연동 예정").
*/ */
import { useEffect, useState } from 'react'; 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 { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons'; import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons';
import { import {
REFERENCE_TABS, auctionApi,
SAMPLE_RANKS,
formatWon, formatWon,
type AuctionDetail,
type MaterialKind, type MaterialKind,
type RankRow, type RankRow,
} from './sampleAuction'; } from './auctionApi';
import { SampleBadge, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import './auction.css'; import './auction.css';
const REF_DESC: Record<MaterialKind, { title: string; desc: string; ai?: boolean }> = { const REF_DESC: Record<MaterialKind, { label: string; title: string; desc: string; ai?: boolean }> = {
layout: { layout: {
label: '배치도',
title: '배치도 (M2)', title: '배치도 (M2)',
desc: 'A-102 부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.', desc: '부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.',
}, },
design: { design: {
label: '부스 설계안',
title: '부스 설계안 (M3)', title: '부스 설계안 (M3)',
desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.', desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.',
}, },
boq: { boq: {
label: '물량서(BOQ)',
title: '물량서 (M4 · BOQ)', title: '물량서 (M4 · BOQ)',
desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.', desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.',
}, },
aiimage: { aiimage: {
label: '예상 이미지',
title: '예상 이미지 (M5)', title: '예상 이미지 (M5)',
desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.', desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.',
ai: true, ai: true,
@ -41,42 +47,116 @@ const REF_DESC: Record<MaterialKind, { title: string; desc: string; ai?: boolean
}; };
export function AuctionDetailPage() { export function AuctionDetailPage() {
const { auctionId = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { show, node: toast } = useToast(); const { show, node: toast } = useToast();
const [tab, setTab] = useState<MaterialKind>('aiimage'); const [tab, setTab] = useState<MaterialKind>('layout');
const [anon, setAnon] = useState(true); 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(() => { 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); 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 (
<div className="kx-page kx-auc">
<Skeleton height={64} radius={12} />
<div style={{ height: 16 }} />
<Skeleton height={420} radius={16} />
</div>
);
}
if (q.isError || !detail) {
return (
<div className="kx-page kx-auc">
<ErrorState message="옥션 상세를 불러오지 못했습니다." onRetry={() => q.refetch()} />
</div>
);
}
const active = REF_DESC[tab]; const active = REF_DESC[tab];
const closed = detail.status !== '진행중';
return ( return (
<div className="kx-page"> <div className="kx-page kx-auc">
{/* 상단 컨텍스트 바 */} {/* 상단 컨텍스트 바 */}
<div className="kx-aucd__bar"> <div className="kx-aucd__bar">
<div className="kx-aucd__bar-left"> <div className="kx-aucd__bar-left">
{!closed && (
<span className="kx-live"> <span className="kx-live">
<span className="kx-live__dot" aria-hidden="true" /> <span className="kx-live__dot" aria-hidden="true" />
</span> </span>
)}
<h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}> <h1 className="kx-auc__title" style={{ fontSize: 'var(--fs-h2)' }}>
A-102 {detail.title}
</h1> </h1>
<span className="kx-tag kx-tag--type"></span> <span className="kx-tag kx-tag--type">{detail.type}</span>
<span className="kx-tag kx-tag--muted"> 2</span> <span className="kx-tag kx-tag--muted"> {detail.round}</span>
<SampleBadge />
</div> </div>
<div className="kx-aucd__bar-metrics"> <div className="kx-aucd__bar-metrics">
<div className="kx-aucd__metric"> <div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"></span> <span className="kx-aucd__metric-label"></span>
<span className="kx-aucd__metric-value kx-aucd__metric-value--ok"> </span> <span
className={`kx-aucd__metric-value ${!closed ? 'kx-aucd__metric-value--ok' : ''}`}
>
{detail.status === '진행중' ? '활성 응찰 중' : detail.status}
</span>
</div> </div>
<div className="kx-aucd__metric"> <div className="kx-aucd__metric">
<span className="kx-aucd__metric-label"> </span> <span className="kx-aucd__metric-label"> </span>
<span className="kx-aucd__metric-value tnum">{formatWon(8_400_000)}</span> <span className="kx-aucd__metric-value tnum">
{detail.lowestPrice != null ? formatWon(detail.lowestPrice) : '—'}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -85,15 +165,15 @@ export function AuctionDetailPage() {
{/* 좌 — AI 자료 뷰어 */} {/* 좌 — AI 자료 뷰어 */}
<section className="kx-viewer" aria-label="AI 설계 자료 뷰어"> <section className="kx-viewer" aria-label="AI 설계 자료 뷰어">
<div className="kx-viewer__tabs" role="tablist" aria-label="자료 종류"> <div className="kx-viewer__tabs" role="tablist" aria-label="자료 종류">
{REFERENCE_TABS.map((t) => ( {detail.materials.map((kind) => (
<button <button
key={t.kind} key={kind}
role="tab" role="tab"
aria-selected={tab === t.kind} aria-selected={tab === kind}
className={`kx-viewer__tab ${tab === t.kind ? 'is-active' : ''}`} className={`kx-viewer__tab ${tab === kind ? 'is-active' : ''}`}
onClick={() => setTab(t.kind)} onClick={() => setTab(kind)}
> >
{t.label} {REF_DESC[kind].label}
</button> </button>
))} ))}
</div> </div>
@ -110,7 +190,7 @@ export function AuctionDetailPage() {
type="button" type="button"
className="kx-viewer__tab" className="kx-viewer__tab"
style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }} style={{ color: 'rgba(255,255,255,0.8)', height: 'auto' }}
onClick={() => show('전체화면 뷰어 — 백엔드 연동 예정')} onClick={() => show('전체화면 뷰어는 자료 스토리지 연동 후 제공됩니다.')}
aria-label="전체화면" aria-label="전체화면"
> >
<IconExpand size={18} /> <IconExpand size={18} />
@ -137,30 +217,106 @@ export function AuctionDetailPage() {
</div> </div>
<div className="kx-auc__gate kx-auc__gate--seal" style={{ margin: '0 16px' }}> <div className="kx-auc__gate kx-auc__gate--seal" style={{ margin: '0 16px' }}>
, . {detail.sealed
? '봉인 입찰 — 마감 전 경쟁 견적 금액은 비공개, 최저가만 공개됩니다.'
: '마감됨 — 발주자 견적 비교에서 전체가 공개됩니다.'}
</div> </div>
<div className="kx-rank__list"> <div className="kx-rank__list">
{SAMPLE_RANKS.map((r) => ( {detail.ranking.length === 0 ? (
<RankRowItem key={r.rank} row={r} anon={anon} /> <p className="kx-rank__note" style={{ padding: '8px 16px' }}>
))} .
</p>
) : (
detail.ranking.map((r) => <RankRowItem key={r.rank} row={r} anon={anon} />)
)}
</div> </div>
<div className="kx-rank__foot"> <div className="kx-rank__foot">
{!closed && (
<div className="kx-countdown"> <div className="kx-countdown">
<span className="kx-countdown__label"> </span> <span className="kx-countdown__label"> </span>
<span className="kx-countdown__value">{fmtClock(seconds)}</span> <span className="kx-countdown__value">{fmtClock(seconds)}</span>
</div> </div>
)}
{detail.canBid ? (
<>
<div className="kx-grid-2" style={{ marginBottom: 8 }}>
<div className="kx-field">
<label className="kx-field__label" htmlFor="bid-price">
( )
</label>
<input
id="bid-price"
className="kx-input"
type="number"
min={1}
value={bidPrice}
placeholder="예: 8400000"
onChange={(e) => setBidPrice(e.target.value)}
/>
</div>
<div className="kx-field">
<label className="kx-field__label" htmlFor="bid-lead">
()
</label>
<input
id="bid-lead"
className="kx-input"
type="number"
min={0}
value={leadDays}
placeholder="예: 12"
onChange={(e) => setLeadDays(e.target.value)}
/>
</div>
</div>
<Button <Button
block block
leadingIcon={<IconPlus size={16} />} leadingIcon={<IconPlus size={16} />}
onClick={() => show('견적서 작성(SCR-28) — 백엔드 연동 예정')} onClick={() => {
if (!bidPrice || Number(bidPrice) <= 0) return show('응찰 금액을 입력해 주세요.');
bidM.mutate();
}}
disabled={bidM.isPending}
> >
· {bidM.isPending ? '제출 중…' : '견적서 제출 · 재응찰'}
</Button> </Button>
<p className="kx-rank__note"> <p className="kx-rank__note">
. . ( ).
.
</p> </p>
</>
) : (
<p className="kx-rank__note">
{closed
? '마감된 옥션입니다. 응찰이 종료되었습니다.'
: '응찰 권한이 없습니다 — 킨텍스 등록 장치업체(초대 대상)만 응찰할 수 있습니다.'}
</p>
)}
{detail.canViewAward && (
<Button
variant="secondary"
block
style={{ marginTop: 8 }}
onClick={() => navigate(`/auctions/${auctionId}/award`)}
>
·
</Button>
)}
{!closed && detail.canViewAward === false && isOrdererHint(detail) && (
<Button
variant="ghost"
block
style={{ marginTop: 8 }}
onClick={() => closeM.mutate()}
disabled={closeM.isPending}
>
{closeM.isPending ? '마감 중…' : '라운드 마감(발주자)'}
</Button>
)}
</div> </div>
</aside> </aside>
</div> </div>
@ -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 }) { 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 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 ( return (
<div className={`kx-rank__row ${cls}`}> <div className={`kx-rank__row ${cls}`}>
<span className="kx-rank__badge">{row.rank}</span> <span className="kx-rank__badge">{row.rank}</span>
<div className="kx-rank__main"> <div className="kx-rank__main">
<p className="kx-rank__alias">{name}</p> <p className="kx-rank__alias">{name}</p>
{revealPrice ? ( {!row.priceMasked && row.price != null ? (
<p className="kx-rank__price tnum">{formatWon(row.price)}</p> <p className="kx-rank__price tnum">{formatWon(row.price)}</p>
) : ( ) : (
<p className="kx-rank__price--masked" title="봉인 입찰 — 마감 후 공개"> <p className="kx-rank__price--masked" title="봉인 입찰 — 마감 후 공개">
@ -189,7 +350,10 @@ function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) {
</div> </div>
{row.isLowest && <span className="kx-rank__tag"></span>} {row.isLowest && <span className="kx-rank__tag"></span>}
{row.isMe && ( {row.isMe && (
<span className="kx-rank__tag" style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}> <span
className="kx-rank__tag"
style={{ background: 'var(--color-ai-surface)', color: 'var(--color-ai-accent)' }}
>
</span> </span>
)} )}

View File

@ -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 . * 보안: 개설· ( id ). 403.
* · + "백엔드 연동 예정" .
* 보안: 등록업체만 (verified ) .
*/ */
import { useMemo, useState, type ReactNode } from 'react'; 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 { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge'; import { DdayChip } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import {
IconPlus, IconPlus,
IconDocument, IconDocument,
IconFloorplan, IconFloorplan,
IconImage, IconImage,
IconSpark,
IconSettings, IconSettings,
} from '../../components/ui/icons'; } from '../../components/ui/icons';
import { import {
INVITABLE_COMPANIES,
MATERIAL_LABEL, MATERIAL_LABEL,
SAMPLE_AUCTIONS, auctionApi,
formatWon, formatWon,
type AuctionStatus, type AuctionStatus,
type AuctionSummary, type AuctionSummary,
type AwardCriteria, type AwardCriteria,
type AuctionType,
type MaterialKind, type MaterialKind,
} from './sampleAuction'; } from './auctionApi';
import { SampleBadge, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import './auction.css'; import './auction.css';
type TabKey = 'live' | 'closed' | 'mine'; type TabKey = 'live' | 'closed' | 'mine';
const TABS: { key: TabKey; label: string }[] = [ const TABS: { key: TabKey; label: string }[] = [
{ key: 'live', label: '진행중' }, { key: 'live', label: '진행중' },
{ key: 'closed', label: '마감' }, { key: 'closed', label: '마감' },
{ key: 'mine', label: '내가 개설' }, { key: 'mine', label: '역경매' },
]; ];
const CATEGORIES = ['전시디자인설치', '구조물 임대', '전기/조명', '영상/음향', '네트워크'];
const MATERIAL_KINDS: MaterialKind[] = ['layout', 'design', 'boq', 'aiimage'];
const MAT_ICON: Record<MaterialKind, ReactNode> = { const MAT_ICON: Record<MaterialKind, ReactNode> = {
layout: <IconFloorplan size={16} />, layout: <IconFloorplan size={16} />,
design: <IconSettings size={16} />, design: <IconSettings size={16} />,
@ -45,28 +48,77 @@ const MAT_ICON: Record<MaterialKind, ReactNode> = {
}; };
export function AuctionListPage() { export function AuctionListPage() {
const qc = useQueryClient();
const navigate = useNavigate();
const { show, node: toast } = useToast(); const { show, node: toast } = useToast();
const [tab, setTab] = useState<TabKey>('live'); const [tab, setTab] = useState<TabKey>('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<AuctionType>('역경매');
const [criteria, setCriteria] = useState<AwardCriteria>('comprehensive'); const [criteria, setCriteria] = useState<AwardCriteria>('comprehensive');
const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 }); const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 });
const [invited, setInvited] = useState<Record<string, boolean>>({ const [round, setRound] = useState(1);
'(주)에이치디자인': true, const [deadline, setDeadline] = useState('');
const [materials, setMaterials] = useState<Set<MaterialKind>>(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(() => { const visible = useMemo(() => {
if (tab === 'closed') return SAMPLE_AUCTIONS.filter((a) => a.status !== '진행중'); if (tab === 'closed') return auctions.filter((a) => a.status !== '진행중');
if (tab === 'mine') return SAMPLE_AUCTIONS.filter((a) => a.type === '역경매'); if (tab === 'mine') return auctions.filter((a) => a.type === '역경매');
return SAMPLE_AUCTIONS.filter((a) => a.status === '진행중'); return auctions.filter((a) => a.status === '진행중');
}, [tab]); }, [tab, auctions]);
function submitCreate() {
if (!effectiveEventId) return show('행사를 선택해 주세요.');
if (!title.trim()) return show('공고명을 입력해 주세요.');
if (!deadline) return show('마감 기한을 선택해 주세요.');
createM.mutate();
}
return ( return (
<div className="kx-page"> <div className="kx-page kx-auc">
<header className="kx-auc__head"> <header className="kx-auc__head">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-auc__title">· </h1> <h1 className="kx-auc__title">· </h1>
<SampleBadge />
</div>
<p className="kx-auc__subtitle">AI (M2~M5) · </p> <p className="kx-auc__subtitle">AI (M2~M5) · </p>
</div> </div>
<div className="kx-auc__head-actions"> <div className="kx-auc__head-actions">
@ -89,14 +141,26 @@ export function AuctionListPage() {
<div className="kx-auc__split"> <div className="kx-auc__split">
{/* 좌 — 옥션 카드 목록 */} {/* 좌 — 옥션 카드 목록 */}
<section className="kx-auc__list" aria-label="옥션 목록"> <section className="kx-auc__list" aria-label="옥션 목록">
{visible.length === 0 ? ( {q.isLoading && (
<>
<Skeleton height={168} radius={16} />
<Skeleton height={168} radius={16} />
</>
)}
{q.isError && !q.isLoading && (
<ErrorState message="옥션 목록을 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && visible.length === 0 && (
<EmptyState <EmptyState
title="진행 중인 옥션이 없습니다" title="해당 조건의 옥션이 없습니다"
description="새로운 시공 옥션을 개설해 협력사를 모집하세요." description="새로운 시공 옥션을 개설해 협력사를 모집하세요."
/> />
) : (
visible.map((a) => <AuctionCard key={a.id} auction={a} onOpen={() => show(`${a.title} 상세 — 백엔드 연동 예정`)} />)
)} )}
{!q.isLoading &&
!q.isError &&
visible.map((a) => (
<AuctionCard key={a.id} auction={a} onOpen={() => navigate(`/auctions/${a.id}`)} />
))}
</section> </section>
{/* 우 — 옥션 개설 패널 */} {/* 우 — 옥션 개설 패널 */}
@ -109,18 +173,77 @@ export function AuctionListPage() {
</div> </div>
<div className="kx-auc-open__body"> <div className="kx-auc-open__body">
<div className="kx-field"> <div className="kx-field">
<label className="kx-field__label" htmlFor="auc-cat"> <label className="kx-field__label" htmlFor="auc-event">
</label> </label>
<select id="auc-cat" className="kx-input"> <select
<option></option> id="auc-event"
<option> </option> className="kx-input"
<option>/</option> value={effectiveEventId}
<option>/</option> onChange={(e) => setEventId(e.target.value)}
<option></option> >
{eventIds.length === 0 && <option value=""> </option>}
{eventIds.map((eid) => (
<option key={eid} value={eid}>
{eid}
</option>
))}
</select> </select>
</div> </div>
<div className="kx-field">
<label className="kx-field__label" htmlFor="auc-title">
</label>
<input
id="auc-title"
className="kx-input"
type="text"
value={title}
placeholder="예: A-102 독립부스 시공"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="kx-grid-2">
<div className="kx-field">
<label className="kx-field__label" htmlFor="auc-cat">
</label>
<select
id="auc-cat"
className="kx-input"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</div>
<div className="kx-field">
<span className="kx-field__label"> </span>
<div className="kx-seg" role="tablist" aria-label="옥션 유형">
<button
role="tab"
aria-selected={type === '역경매'}
className={`kx-seg__btn ${type === '역경매' ? 'is-active' : ''}`}
onClick={() => setType('역경매')}
>
</button>
<button
role="tab"
aria-selected={type === 'RFQ'}
className={`kx-seg__btn ${type === 'RFQ' ? 'is-active' : ''}`}
onClick={() => setType('RFQ')}
>
RFQ
</button>
</div>
</div>
</div>
<div className="kx-field"> <div className="kx-field">
<span className="kx-field__label"> (Award Criteria)</span> <span className="kx-field__label"> (Award Criteria)</span>
<div className="kx-seg" role="tablist" aria-label="낙찰 기준"> <div className="kx-seg" role="tablist" aria-label="낙찰 기준">
@ -168,66 +291,64 @@ export function AuctionListPage() {
<label className="kx-field__label" htmlFor="auc-round"> <label className="kx-field__label" htmlFor="auc-round">
(Round) (Round)
</label> </label>
<input id="auc-round" className="kx-input" type="text" defaultValue="1차" /> <input
id="auc-round"
className="kx-input"
type="number"
min={1}
value={round}
onChange={(e) => setRound(Math.max(1, Number(e.target.value) || 1))}
/>
</div> </div>
<div className="kx-field"> <div className="kx-field">
<label className="kx-field__label" htmlFor="auc-deadline"> <label className="kx-field__label" htmlFor="auc-deadline">
</label> </label>
<input id="auc-deadline" className="kx-input" type="date" /> <input
id="auc-deadline"
className="kx-input"
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
</div> </div>
</div> </div>
<div className="kx-field"> <div className="kx-field">
<span className="kx-field__label"> </span> <span className="kx-field__label"> (M2~M5)</span>
<button <div className="kx-invite" role="group" aria-label="첨부 자료">
type="button" {MATERIAL_KINDS.map((m) => (
className="kx-auc-attach" <label key={m} className="kx-invite__row">
onClick={() => show('M2~M5 자료 선택기 — 백엔드 연동 예정')} <span>{MATERIAL_LABEL[m]}</span>
>
<IconSpark size={26} />
M2~M5
<small> · · (BOQ) · AI </small>
</button>
</div>
<div className="kx-field">
<label className="kx-field__label">
<span className="kx-field__label-note"> </span>
</label>
<div className="kx-invite">
{INVITABLE_COMPANIES.map((c) => (
<label
key={c.name}
className={`kx-invite__row ${c.verified ? '' : 'kx-invite__row--blocked'}`}
>
<span>{c.name}</span>
{c.verified ? (
<input <input
type="checkbox" type="checkbox"
checked={!!invited[c.name]} checked={materials.has(m)}
onChange={(e) => onChange={(e) =>
setInvited((prev) => ({ ...prev, [c.name]: e.target.checked })) setMaterials((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(m);
else next.delete(m);
return next;
})
} }
aria-label={`${c.name} 초대`} aria-label={`${MATERIAL_LABEL[m]} 첨부`}
/> />
) : (
<span className="kx-invite__blocked-tag" title="킨텍스 미등록 업체 — 응찰 불가">
·
</span>
)}
</label> </label>
))} ))}
</div> </div>
</div> </div>
<div className="kx-auc__gate">
. .
</div>
<Button <Button
block block
leadingIcon={<IconPlus size={16} />} leadingIcon={<IconPlus size={16} />}
onClick={() => show('옥션 공고 게시 — 백엔드 연동 예정')} onClick={submitCreate}
disabled={createM.isPending}
> >
{createM.isPending ? '게시 중…' : '옥션 공고 게시'}
</Button> </Button>
</div> </div>
</aside> </aside>
@ -319,7 +440,6 @@ function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: ()
</div> </div>
</div> </div>
{!closed && (
<div className="kx-auc-card__foot"> <div className="kx-auc-card__foot">
<div className="kx-auc-card__mats"> <div className="kx-auc-card__mats">
{auction.materials.map((m) => ( {auction.materials.map((m) => (
@ -333,7 +453,6 @@ function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: ()
</Button> </Button>
</div> </div>
)}
</article> </article>
); );
} }

View File

@ -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 { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; 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 { IconAiBot, IconCheckCircle, IconDownload, IconWarning } from '../../components/ui/icons';
import { RISK_BARS, SAMPLE_QUOTES, formatWon, type QuoteColumn } from './sampleAuction'; import { auctionApi, formatWon, type Quote } from './auctionApi';
import { SampleBadge, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import './auction.css'; import './auction.css';
export function AwardComparePage() { /** 인라인 별점 아이콘(★ 텍스트 글리프 대체 — stroke SVG). */
const { show, node: toast } = useToast(); function IconStar({ size = 14 }: { size?: number }) {
const quotes = SAMPLE_QUOTES; return (
const recommended = quotes.find((q) => q.recommended) ?? quotes[0]; <svg
const [reason, setReason] = useState(''); width={size}
const [awarded, setAwarded] = useState(false); height={size}
viewBox="0 0 24 24"
const onAward = () => { fill="currentColor"
if (!reason.trim()) { aria-hidden="true"
show('선정 사유를 입력해 주세요.'); focusable="false"
return; >
<path d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z" />
</svg>
);
}
export function AwardComparePage() {
const { auctionId = '' } = useParams();
const qc = useQueryClient();
const { show, node: toast } = useToast();
const [reason, setReason] = useState('');
const [selectedBidId, setSelectedBidId] = useState<string | null>(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 (
<div className="kx-page kx-auc">
<Skeleton height={64} radius={12} />
<div style={{ height: 16 }} />
<Skeleton height={360} radius={16} />
</div>
);
}
if (q.isError) {
const msg =
(q.error as { code?: string })?.code === 'FORBIDDEN'
? '마감 후 발주자만 견적 비교·낙찰을 열람할 수 있습니다.'
: '견적 비교 정보를 불러오지 못했습니다.';
return (
<div className="kx-page kx-auc">
<ErrorState message={msg} onRetry={() => q.refetch()} />
</div>
);
} }
setAwarded(true);
show(`${recommended.alias} 낙찰 승인 — 계약·발주 전환은 백엔드 연동 예정`);
};
return ( return (
<div className="kx-page"> <div className="kx-page kx-auc">
<header className="kx-auc__head"> <header className="kx-auc__head">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-auc__title"> </h1> <h1 className="kx-auc__title"> </h1>
<SampleBadge /> <p className="kx-auc__subtitle">
{view?.auctionTitle ?? ''} · ( )
</p>
</div> </div>
<p className="kx-auc__subtitle">A-102 · ( )</p> <Button
</div> variant="secondary"
<Button variant="secondary" leadingIcon={<IconDownload size={16} />} onClick={() => show('응찰서 원본 다운로드 — 백엔드 연동 예정')}> leadingIcon={<IconDownload size={16} />}
onClick={() => show('응찰서 원본 다운로드는 견적서 PDF 산출 연동 후 제공됩니다.')}
>
</Button> </Button>
</header> </header>
@ -47,15 +109,19 @@ export function AwardComparePage() {
<div className="kx-award__summary"> <div className="kx-award__summary">
<div className="kx-award__chip"> <div className="kx-award__chip">
<span className="kx-award__chip-label"></span> <span className="kx-award__chip-label"></span>
<span className="kx-award__chip-value">A-102 </span> <span className="kx-award__chip-value">{view?.auctionTitle ?? '—'}</span>
</div> </div>
<div className="kx-award__chip"> <div className="kx-award__chip">
<span className="kx-award__chip-label"> </span> <span className="kx-award__chip-label"> </span>
<span className="kx-award__chip-value"> {quotes.length} </span> <span className="kx-award__chip-value"> {view?.bidderCount ?? 0} </span>
</div> </div>
<div className="kx-award__chip"> <div className="kx-award__chip">
<span className="kx-award__chip-label"> </span> <span className="kx-award__chip-label"> </span>
<span className="kx-award__chip-value"> ( 40% + 60%)</span> <span className="kx-award__chip-value">
{view?.awardCriteria === 'lowest'
? '최저가'
: `종합평가 (가격 ${view?.weights.price ?? 0}% · 평판 ${view?.weights.reputation ?? 0}% · 납기 ${view?.weights.delivery ?? 0}%)`}
</span>
</div> </div>
</div> </div>
@ -75,14 +141,21 @@ export function AwardComparePage() {
<thead> <thead>
<tr> <tr>
<th className="kx-compare__metric-col"> </th> <th className="kx-compare__metric-col"> </th>
{quotes.map((q) => ( {quotes.map((qc2) => (
<th <th
key={q.alias} key={qc2.bidId}
className={`kx-compare__co ${q.recommended ? 'kx-compare__co--rec' : ''}`} className={`kx-compare__co ${qc2.bidId === selected?.bidId ? 'kx-compare__co--rec' : ''}`}
> >
{q.recommended && <span className="kx-compare__rec-tag"> </span>} {qc2.recommended && <span className="kx-compare__rec-tag"> </span>}
<span className="kx-compare__co-name">{q.alias}</span> <button
<span className="kx-compare__co-sub">{q.subtitle}</span> type="button"
className="kx-compare__co-select"
aria-pressed={qc2.bidId === selected?.bidId}
onClick={() => setSelectedBidId(qc2.bidId)}
>
<span className="kx-compare__co-name">{qc2.alias}</span>
<span className="kx-compare__co-sub">{qc2.subtitle}</span>
</button>
</th> </th>
))} ))}
</tr> </tr>
@ -90,23 +163,25 @@ export function AwardComparePage() {
<tbody> <tbody>
<tr> <tr>
<td className="kx-compare__metric-col"> (VAT )</td> <td className="kx-compare__metric-col"> (VAT )</td>
{quotes.map((q) => ( {quotes.map((qc2) => (
<td key={q.alias} className={q.isLowestPrice ? 'kx-compare__best' : ''}> <td key={qc2.bidId} className={qc2.isLowestPrice ? 'kx-compare__best' : ''}>
<span className="kx-compare__val-strong tnum">{formatWon(q.totalPrice)}</span> <span className="kx-compare__val-strong tnum">
{formatWon(qc2.totalPrice)}
</span>
<span <span
className={`kx-compare__note ${q.isLowestPrice ? 'kx-compare__note--best' : ''}`} className={`kx-compare__note ${qc2.isLowestPrice ? 'kx-compare__note--best' : ''}`}
> >
{q.isLowestPrice ? '최저가' : `+${q.priceDeltaPct}% 차이`} {qc2.isLowestPrice ? '최저가' : `+${qc2.priceDeltaPct}% 차이`}
</span> </span>
</td> </td>
))} ))}
</tr> </tr>
<tr> <tr>
<td className="kx-compare__metric-col"> ( )</td> <td className="kx-compare__metric-col"> ( )</td>
{quotes.map((q) => ( {quotes.map((qc2) => (
<td key={q.alias} className={q.isShortestLead ? 'kx-compare__best' : ''}> <td key={qc2.bidId} className={qc2.isShortestLead ? 'kx-compare__best' : ''}>
<span className="tnum">{q.leadDays}</span> <span className="tnum">{qc2.leadDays}</span>
{q.isShortestLead && ( {qc2.isShortestLead && (
<span className="kx-compare__note kx-compare__note--best"> </span> <span className="kx-compare__note kx-compare__note--best"> </span>
)} )}
</td> </td>
@ -114,27 +189,33 @@ export function AwardComparePage() {
</tr> </tr>
<tr> <tr>
<td className="kx-compare__metric-col"> ( 1)</td> <td className="kx-compare__metric-col"> ( 1)</td>
{quotes.map((q) => { {quotes.map((qc2) => {
const best = q.reputation === Math.max(...quotes.map((x) => x.reputation)); const best =
qc2.reputation === Math.max(...quotes.map((x) => x.reputation));
return ( return (
<td key={q.alias} className={best ? 'kx-compare__best' : ''}> <td key={qc2.bidId} className={best ? 'kx-compare__best' : ''}>
<span className="kx-compare__star" aria-hidden="true"> <span className="kx-compare__star">
<IconStar size={14} />
</span>{' '} </span>{' '}
<span className="tnum">{q.reputation.toFixed(1)}</span> <span className="tnum">{qc2.reputation.toFixed(1)}</span>
</td> </td>
); );
})} })}
</tr> </tr>
<tr> <tr>
<td className="kx-compare__metric-col"> </td> <td className="kx-compare__metric-col"> </td>
{quotes.map((q) => ( {quotes.map((qc2) => (
<td key={q.alias} className={q.recommended ? 'kx-compare__score' : ''}> <td key={qc2.bidId} className={qc2.recommended ? 'kx-compare__score' : ''}>
{q.recommended ? ( {qc2.recommended ? (
<span className="kx-compare__score-val tnum">{q.score.toFixed(1)}</span> <span className="kx-compare__score-val tnum">
{qc2.score.toFixed(1)}
</span>
) : ( ) : (
<span className="kx-compare__val-strong tnum" style={{ color: 'var(--color-neutral-500)' }}> <span
{q.score.toFixed(1)} className="kx-compare__val-strong tnum"
style={{ color: 'var(--color-neutral-500)' }}
>
{qc2.score.toFixed(1)}
</span> </span>
)} )}
</td> </td>
@ -153,7 +234,7 @@ export function AwardComparePage() {
</div> </div>
<div className="kx-risk"> <div className="kx-risk">
<ul className="kx-barlist"> <ul className="kx-barlist">
{RISK_BARS.map((b) => ( {(view?.riskBars ?? []).map((b) => (
<li key={b.label}> <li key={b.label}>
<div className="kx-barlist__top"> <div className="kx-barlist__top">
<span>{b.label}</span> <span>{b.label}</span>
@ -173,29 +254,20 @@ export function AwardComparePage() {
<div className="kx-barlist__track"> <div className="kx-barlist__track">
<span <span
className={`kx-barlist__fill ${b.tone === 'success' ? 'kx-barlist__fill--success' : 'kx-barlist__fill--ai'}`} className={`kx-barlist__fill ${b.tone === 'success' ? 'kx-barlist__fill--success' : 'kx-barlist__fill--ai'}`}
style={{ width: `${b.pct}%` }} style={{ width: `${Math.min(b.pct, 100)}%` }}
/> />
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<div className="kx-risk__grade">
<span className="kx-award__rec-cell-label">AI </span>
<span className="kx-risk__grade-value">A+</span>
<span style={{ fontSize: 11, color: 'var(--color-success)' }}> </span>
</div> </div>
</div>
<p className="kx-card__hint" style={{ marginTop: 12 }}>
A는 3 KINTEX (100~150) 12 , 0%
.
</p>
</section> </section>
</div> </div>
{/* 우 — 낙찰 선정 패널 */} {/* 우 — 낙찰 선정 패널 */}
<aside className="kx-award__panel" aria-label="낙찰 선정"> <aside className="kx-award__panel" aria-label="낙찰 선정">
<h2> </h2> <h2> </h2>
<RecommendCard quote={recommended} /> {selected && <RecommendCard quote={selected} />}
<div className="kx-field"> <div className="kx-field">
<label className="kx-field__label" htmlFor="award-reason"> <label className="kx-field__label" htmlFor="award-reason">
@ -205,23 +277,24 @@ export function AwardComparePage() {
id="award-reason" id="award-reason"
className="kx-award__textarea" className="kx-award__textarea"
placeholder="종합 평가 결과 및 업체 강점을 입력하세요. 예: 최저가 응찰 + 과거 동일 규모 행사 수행 실적 우수" placeholder="종합 평가 결과 및 업체 강점을 입력하세요. 예: 최저가 응찰 + 과거 동일 규모 행사 수행 실적 우수"
value={reason} value={alreadyAwarded ? (view?.awarded?.awardedAt ? '낙찰 완료' : reason) : reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
disabled={awarded} disabled={alreadyAwarded}
/> />
</div> </div>
<Button <Button
variant={awarded ? 'secondary' : 'primary'} variant={alreadyAwarded ? 'secondary' : 'primary'}
block block
leadingIcon={<IconCheckCircle size={16} />} leadingIcon={<IconCheckCircle size={16} />}
onClick={onAward} onClick={onAward}
disabled={awarded} disabled={alreadyAwarded || awardM.isPending}
> >
{awarded ? '낙찰 승인 완료' : '낙찰(Award) 최종 승인'} {alreadyAwarded
</Button> ? '낙찰 승인 완료'
<Button variant="ghost" block onClick={() => show('재입찰 요청 — 백엔드 연동 예정')}> : awardM.isPending
? '승인 중…'
: '낙찰(Award) 최종 승인'}
</Button> </Button>
<div className="kx-award__warn"> <div className="kx-award__warn">
@ -254,17 +327,17 @@ export function AwardComparePage() {
); );
} }
function RecommendCard({ quote }: { quote: QuoteColumn }) { function RecommendCard({ quote }: { quote: Quote }) {
return ( return (
<div className="kx-award__rec"> <div className="kx-award__rec">
<div className="kx-award__rec-top"> <div className="kx-award__rec-top">
<span className="kx-award__rec-avatar">{quote.alias.replace(/[^A-Za-z]/g, '') || 'A'}</span> <span className="kx-award__rec-avatar">{quote.alias.slice(0, 2)}</span>
<div> <div>
<p className="kx-award__rec-name"> <p className="kx-award__rec-name">
{quote.alias} ({quote.subtitle}) {quote.alias} ({quote.subtitle})
</p> </p>
<span className="kx-award__rec-ai"> <span className="kx-award__rec-ai">
<IconAiBot size={16} /> 98% <IconAiBot size={16} /> {quote.score.toFixed(1)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
/* 옥션 화면 공용 소도구 — 데모 토스트(뮤테이션 로컬 처리 안내). */ /* 옥션 화면 공용 소도구 — 토스트(성공/오류) + 오류 메시지 추출. */
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { IconCheckCircle } from '../../components/ui/icons'; import { IconCheckCircle } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
export function useToast() { export function useToast() {
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
@ -19,11 +20,21 @@ export function useToast() {
return { show, node }; return { show, node };
} }
/** 화면 상단 공용 "샘플 데이터" 배지. */ /** 서버 오류 봉투 → 사용자 메시지. 봉인/등록업체/마감 코드는 도메인 문구로 치환. */
export function SampleBadge() { export function errMessage(e: unknown): string {
return ( if (e instanceof ApiRequestError) {
<span className="kx-auc__sample" title="M15 역경매 엔진 미구현 — 시연용 정적 데이터"> switch (e.code) {
case 'NOT_REGISTERED_COMPANY':
</span> return '킨텍스 등록업체만 응찰할 수 있습니다.';
); case 'FORBIDDEN':
return '이 작업에 대한 권한이 없습니다.';
case 'CONFLICT':
return '마감되었거나 현재 상태와 충돌하여 처리할 수 없습니다.';
case 'VALIDATION':
return e.message || '입력값을 확인해 주세요.';
default:
return e.message || '요청을 처리하지 못했습니다.';
}
}
return '요청을 처리하지 못했습니다.';
} }

View File

@ -255,12 +255,14 @@
max-height: 720px; max-height: 720px;
overflow-y: auto; overflow-y: auto;
} }
.kx-field { /* ★ 폼 유틸(kx-field*/kx-input/kx-grid-2) .kx-auc 스코프 격리(QA: 전역 재정의 방지).
옥션/낙찰 화면 루트에 .kx-auc 부여하므로 화면의 동명 클래스에 영향을 주지 않는다. */
.kx-auc .kx-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.kx-field__label { .kx-auc .kx-field__label {
font-size: var(--fs-caption); font-size: var(--fs-caption);
color: var(--color-neutral-700); color: var(--color-neutral-700);
font-weight: 600; font-weight: 600;
@ -268,12 +270,12 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.kx-field__label-note { .kx-auc .kx-field__label-note {
font-size: 11px; font-size: 11px;
color: var(--color-error); color: var(--color-error);
font-weight: 500; font-weight: 500;
} }
.kx-input { .kx-auc .kx-input {
height: 36px; height: 36px;
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-card); border: var(--border-card);
@ -283,11 +285,26 @@
color: var(--color-neutral-900); color: var(--color-neutral-900);
width: 100%; width: 100%;
} }
.kx-grid-2 { .kx-auc .kx-grid-2 {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-3); gap: var(--space-3);
} }
/* 비교표 컬럼 선택 버튼(낙찰 대상 선택) — 헤더 텍스트 스택을 버튼으로 감싼다. */
.kx-compare__co-select {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
}
.kx-compare__co-select[aria-pressed='true'] .kx-compare__co-name {
color: var(--color-primary, var(--color-ai-accent));
}
/* 가중치 슬라이더 카드 */ /* 가중치 슬라이더 카드 */
.kx-weights { .kx-weights {

View File

@ -86,12 +86,20 @@ export interface BidResult {
round: number; round: number;
subtotal: number; subtotal: number;
vat: number; vat: number;
total: number; total: number; // 경쟁 순위 금액(부가세 별도)
grandTotal: number; // 부가세 포함 총액
version: number; version: number;
myRank: number | null; myRank: number | null;
lowestPrice: number | null; lowestPrice: number | null;
} }
export interface QuoteLine {
trade: string;
material: string;
qty: number;
unitPrice: number;
}
export interface Quote { export interface Quote {
bidId: string; bidId: string;
companyId: string; companyId: string;
@ -181,10 +189,11 @@ export interface CreateAuctionBody {
} }
export interface BidBody { export interface BidBody {
total: number; total?: number; // 라인아이템 미전송 시 경쟁 금액(부가세 별도)
leadDays?: number; leadDays?: number;
validUntil?: string | null; validUntil?: string | null;
terms?: string | null; terms?: string | null;
lines?: QuoteLine[];
} }
export interface AwardBody { export interface AwardBody {
@ -210,6 +219,7 @@ export const auctionApi = {
detail: (id: string) => api.get<AuctionDetail>(`/api/auctions/${encodeURIComponent(id)}`), detail: (id: string) => api.get<AuctionDetail>(`/api/auctions/${encodeURIComponent(id)}`),
create: (body: CreateAuctionBody) => api.post<AuctionSummary>('/api/auctions', body), create: (body: CreateAuctionBody) => api.post<AuctionSummary>('/api/auctions', body),
bid: (id: string, body: BidBody) => api.post<BidResult>(`/api/auctions/${encodeURIComponent(id)}/bids`, body), bid: (id: string, body: BidBody) => api.post<BidResult>(`/api/auctions/${encodeURIComponent(id)}/bids`, body),
close: (id: string) => api.post<AuctionSummary>(`/api/auctions/${encodeURIComponent(id)}/close`),
award: (id: string, body: AwardBody) => api.post<AwardResult>(`/api/auctions/${encodeURIComponent(id)}/award`, body), award: (id: string, body: AwardBody) => api.post<AwardResult>(`/api/auctions/${encodeURIComponent(id)}/award`, body),
awardView: (id: string) => api.get<AwardView>(`/api/auctions/${encodeURIComponent(id)}/award-view`), awardView: (id: string) => api.get<AwardView>(`/api/auctions/${encodeURIComponent(id)}/award-view`),
}; };

View File

@ -41,6 +41,38 @@ export interface CmsTranslation {
updatedAt: string | null; updatedAt: string | null;
} }
export interface CmsContentVersion {
id: string;
contentId: string;
versionNo: number;
title: string | null;
body: string | null;
status: FlowStatus;
authorName: string | null;
note: string | null;
createdAt: string | null;
}
export interface CmsMedia {
id: string;
eventId: string | null;
fileName: string;
url: string;
contentType: string | null;
sizeBytes: number | null;
altText: string | null;
uploaderName: string | null;
createdAt: string | null;
}
export interface ContentUpdateRequest {
title: string;
body?: string | null;
scheduledAt?: string | null;
signage?: boolean | null;
mailing?: boolean | null;
}
export interface MicrositeSection { export interface MicrositeSection {
id: string; id: string;
type: string; type: string;
@ -102,12 +134,37 @@ export const cmsApi = {
return api.get<PageResponse<CmsContent>>(`/api/cms/contents?${qs.toString()}`); return api.get<PageResponse<CmsContent>>(`/api/cms/contents?${qs.toString()}`);
}, },
createContent: (body: ContentCreateRequest) => api.post<CmsContent>('/api/cms/contents', body), createContent: (body: ContentCreateRequest) => api.post<CmsContent>('/api/cms/contents', body),
getContent: (id: string) => api.get<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}`),
updateContent: (id: string, body: ContentUpdateRequest) =>
api.put<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}`, body),
transition: (id: string, value: FlowStatus) => transition: (id: string, value: FlowStatus) =>
api.patch<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/status?value=${value}`), api.patch<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/status?value=${value}`),
getVersions: (id: string) =>
api.get<CmsContentVersion[]>(`/api/cms/contents/${encodeURIComponent(id)}/versions`),
rollback: (id: string, versionNo: number) =>
api.post<CmsContent>(`/api/cms/contents/${encodeURIComponent(id)}/rollback?versionNo=${versionNo}`),
getTranslations: (id: string) => getTranslations: (id: string) =>
api.get<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`), api.get<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`),
saveTranslation: (id: string, body: TranslationSaveRequest) => saveTranslation: (id: string, body: TranslationSaveRequest) =>
api.put<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`, body), api.put<CmsTranslation[]>(`/api/cms/contents/${encodeURIComponent(id)}/translations`, body),
/** AI 자동 번역(초벌) — 백엔드 미배선 시 501. 프론트는 degraded("준비 중")로 처리. */
aiTranslate: (id: string, lang: Lang) =>
api.post<CmsTranslation>(
`/api/cms/contents/${encodeURIComponent(id)}/translations/ai?lang=${lang}`,
),
// ── 미디어 라이브러리 ──
listMedia: (limit = 60) => api.get<CmsMedia[]>(`/api/cms/media?limit=${limit}`),
uploadMedia: (file: File, altText?: string) => {
const form = new FormData();
form.append('file', file);
if (altText) form.append('altText', altText);
return api.postForm<CmsMedia>('/api/cms/media', form);
},
// ── 공개 조회(무인증·게시 상태만) ──
publicByType: (type: string, limit = 50) =>
api.get<CmsContent[]>(`/api/public/cms/${encodeURIComponent(type)}?limit=${limit}`, {
anonymous: true,
}),
getMicrosite: (exhibitorId: string) => getMicrosite: (exhibitorId: string) =>
api.get<Microsite>(`/api/exhibitors/${encodeURIComponent(exhibitorId)}/microsite`), api.get<Microsite>(`/api/exhibitors/${encodeURIComponent(exhibitorId)}/microsite`),
saveMicrosite: (exhibitorId: string, body: MicrositeSaveRequest) => saveMicrosite: (exhibitorId: string, body: MicrositeSaveRequest) =>

View File

@ -1,125 +1,65 @@
/* /*
* SCR-38 ( ). Stitch scr_38_contractor_booth_dashboard . * SCR-38 ( ) [M15]. API .
* 대상: 장치·. KPI + ( ) + · . * 정본: GET /api/contractor/dashboard (ContractorDashboard).
* M3·M15 ("샘플 데이터" ). API fetch . * 대상: 장치·. KPI + + · .
* ("백엔드 연동 예정"). * 보안: 옥션 ( ).
* 보안: 옥션 ( ).
*/ */
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge'; import { DdayChip } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import {
IconCheckCircle, IconCheckCircle,
IconDocument, IconDocument,
IconGrid, IconGrid,
IconSettings,
IconUsers, IconUsers,
IconWarning, IconWarning,
} from '../../components/ui/icons'; } from '../../components/ui/icons';
import { SampleBadge, useToast } from '../auction/aucShared'; import { useToast } from '../auction/aucShared';
import { formatWon } from '../auction/sampleAuction'; import {
contractorApi,
formatWon,
type AwardedBooth,
type MyBid,
} from '../auction/auctionApi';
import './contractor.css'; import './contractor.css';
interface Kpi {
label: string;
value: number;
tone?: 'normal' | 'success' | 'error';
}
const KPIS: Kpi[] = [
{ label: '수주 부스', value: 8 },
{ label: '진행 옥션', value: 3 },
{ label: '응찰 대기', value: 2 },
{ label: '시공 진행', value: 5, tone: 'success' },
{ label: '마감 임박', value: 1, tone: 'error' },
];
const STEPS = ['설계', '규정검증', '반입', '시공', '검수'];
interface AwardedBooth {
event: string;
eventTag: string;
name: string;
booth: string;
client: string;
dday: number | null;
stepIndex: number; // 현재 단계(0-based)
variant: 'primary' | 'sub';
progressPct?: number;
statusText?: string;
}
const BOOTHS: AwardedBooth[] = [
{
event: 'Smart Factory 2026',
eventTag: 'Smart Factory 2026',
name: '스마트팩토리 코리아 2026',
booth: 'A-102',
client: '(주)한빛로보틱스',
dday: 14,
stepIndex: 1,
variant: 'primary',
},
{
event: 'K-Medical Expo 2026',
eventTag: 'K-Medical Expo 2026',
name: 'K-메디컬 엑스포',
booth: 'D-405',
client: '세종메디텍',
dday: null,
stepIndex: 0,
variant: 'sub',
progressPct: 15,
statusText: '시공 예정',
},
];
interface BidRow {
name: string;
price: number;
rank: number;
dday: number;
dim?: boolean;
}
const BIDS: BidRow[] = [
{ name: '전기공사 · Hall 1', price: 24_500_000, rank: 2, dday: 2 },
{ name: '네트워크 가설 · Hall 3', price: 12_000_000, rank: 1, dday: 5 },
{ name: '조명 렌탈 · 전시장 전체', price: 45_000_000, rank: 5, dday: 1, dim: true },
];
interface FeedItem {
text: string;
meta: string;
tone: 'ok' | 'warn' | 'info';
}
const FEED: FeedItem[] = [
{ text: 'Booth A-102 규정 검증 완료', meta: '방금 전 · KINTEX 운영국', tone: 'ok' },
{ text: '안전 교육 갱신 필요 알림', meta: '2시간 전 · 시스템', tone: 'warn' },
{ text: '신규 옥션 공고: 제2전시장 외벽 래핑', meta: '어제 · 입찰관리부', tone: 'info' },
];
export function ContractorBoothDashboardPage() { export function ContractorBoothDashboardPage() {
const { show, node: toast } = useToast(); const navigate = useNavigate();
const { node: toast } = useToast();
const q = useQuery({
queryKey: ['contractor-dashboard'],
queryFn: () => contractorApi.dashboard(),
retry: false,
refetchInterval: 15000,
});
const data = q.data;
return ( return (
<div className="kx-page"> <div className="kx-page">
<header className="kx-cbd__head"> <header className="kx-cbd__head">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="kx-cbd__title"> </h1> <h1 className="kx-cbd__title"> </h1>
<SampleBadge /> <p className="kx-cbd__subtitle">{data?.companyName ?? '업체 포털'}</p>
</div> </div>
<p className="kx-cbd__subtitle">() · </p> <Button variant="secondary" onClick={() => navigate('/auctions')}>
</div>
<Button variant="secondary" onClick={() => show('옥션 참여 목록 — 백엔드 연동 예정')}>
</Button> </Button>
</header> </header>
{q.isLoading && <Skeleton height={96} radius={12} />}
{q.isError && !q.isLoading && (
<ErrorState message="대시보드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && data && (
<>
{/* KPI 밴드 */} {/* KPI 밴드 */}
<section className="kx-cbd__kpis" aria-label="핵심 지표"> <section className="kx-cbd__kpis" aria-label="핵심 지표">
{KPIS.map((k) => ( {data.kpis.map((k) => (
<div <div key={k.label} className={`kx-kpi ${k.tone === 'error' ? 'kx-kpi--error' : ''}`}>
key={k.label}
className={`kx-kpi ${k.tone === 'error' ? 'kx-kpi--error' : ''}`}
>
<span className="kx-kpi__label"> <span className="kx-kpi__label">
{k.tone === 'error' && <IconWarning size={14} />} {k.tone === 'error' && <IconWarning size={14} />}
{k.label} {k.label}
@ -139,14 +79,22 @@ export function ContractorBoothDashboardPage() {
<section className="kx-cbd__col" aria-label="수주 부스 리스트"> <section className="kx-cbd__col" aria-label="수주 부스 리스트">
<div className="kx-card__head" style={{ marginBottom: 0 }}> <div className="kx-card__head" style={{ marginBottom: 0 }}>
<h2 style={{ fontSize: 'var(--fs-h2)' }}> </h2> <h2 style={{ fontSize: 'var(--fs-h2)' }}> </h2>
<Button variant="ghost" onClick={() => show('전체 수주 부스 — 백엔드 연동 예정')}>
</Button>
</div> </div>
{BOOTHS.map((b) => ( {data.awardedBooths.length === 0 ? (
<BoothCard key={b.booth} booth={b} onAction={(label) => show(`${label} — 백엔드 연동 예정`)} /> <EmptyState
))} title="수주한 부스가 없습니다"
description="진행 중인 옥션에 응찰해 시공을 수주하세요."
/>
) : (
data.awardedBooths.map((b) => (
<BoothCard
key={b.auctionId}
booth={b}
onOpen={() => navigate(`/auctions/${b.auctionId}`)}
/>
))
)}
</section> </section>
{/* 우 — 위젯 */} {/* 우 — 위젯 */}
@ -156,14 +104,24 @@ export function ContractorBoothDashboardPage() {
<h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2> <h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2>
</div> </div>
<div className="kx-cbd-widget__rows"> <div className="kx-cbd-widget__rows">
{BIDS.map((bid) => ( {data.myAuctionBids.length === 0 ? (
<BidItem key={bid.name} bid={bid} /> <p className="kx-cbd-bid__price" style={{ padding: '8px 0' }}>
))} .
</p>
) : (
data.myAuctionBids.map((bid) => (
<BidItem
key={bid.auctionId}
bid={bid}
onOpen={() => navigate(`/auctions/${bid.auctionId}`)}
/>
))
)}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
block block
onClick={() => show('옥션 대시보드 — 백엔드 연동 예정')} onClick={() => navigate('/auctions')}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
> >
@ -175,7 +133,10 @@ export function ContractorBoothDashboardPage() {
<h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2> <h2 style={{ fontSize: 'var(--fs-h3)' }}> </h2>
</div> </div>
<div className="kx-feed"> <div className="kx-feed">
{FEED.map((f) => ( {data.feed.length === 0 ? (
<p className="kx-feed__meta"> .</p>
) : (
data.feed.map((f) => (
<div key={f.text} className="kx-feed__item"> <div key={f.text} className="kx-feed__item">
<span className={`kx-feed__dot kx-feed__dot--${f.tone}`}> <span className={`kx-feed__dot kx-feed__dot--${f.tone}`}>
{f.tone === 'ok' ? ( {f.tone === 'ok' ? (
@ -189,98 +150,69 @@ export function ContractorBoothDashboardPage() {
<p className="kx-feed__text">{f.text}</p> <p className="kx-feed__text">{f.text}</p>
<p className="kx-feed__meta">{f.meta}</p> <p className="kx-feed__meta">{f.meta}</p>
</div> </div>
))} ))
)}
</div> </div>
</div> </div>
</aside> </aside>
</div> </div>
</>
)}
{toast} {toast}
</div> </div>
); );
} }
function BoothCard({ booth, onAction }: { booth: AwardedBooth; onAction: (label: string) => void }) { function BoothCard({ booth, onOpen }: { booth: AwardedBooth; onOpen: () => void }) {
return ( return (
<article className={`kx-booth ${booth.variant === 'sub' ? 'kx-booth--sub' : ''}`}> <article className="kx-booth">
<div className="kx-booth__top"> <div className="kx-booth__top">
<div> <div>
<span className="kx-booth__event-tag">{booth.eventTag}</span> <span className="kx-booth__event-tag">{booth.event}</span>
<h4 className="kx-booth__name">{booth.name}</h4> <h4 className="kx-booth__name">{booth.name}</h4>
<div className="kx-booth__meta"> <div className="kx-booth__meta">
<span> <span>
<IconGrid size={14} /> Booth {booth.booth} <IconGrid size={14} /> Booth {booth.booth || '—'}
</span> </span>
{booth.client && (
<span> <span>
<IconUsers size={14} /> {booth.client} <IconUsers size={14} /> {booth.client}
</span> </span>
)}
</div> </div>
</div> </div>
<div className="kx-booth__right"> <div className="kx-booth__right">
{booth.dday != null ? (
<>
<p className="kx-booth__right-label"> </p>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
<DdayChip dday={booth.dday} />
</div>
</>
) : (
<>
<p className="kx-booth__right-label"></p> <p className="kx-booth__right-label"></p>
<p className="kx-booth__right-value" style={{ color: 'var(--color-success)', fontSize: 'var(--fs-body)' }}> <p
{booth.statusText} className="kx-booth__right-value"
style={{ color: 'var(--color-success)', fontSize: 'var(--fs-body)' }}
>
{booth.stage}
</p> </p>
</>
)}
</div> </div>
</div> </div>
{booth.variant === 'primary' ? (
<>
<div className="kx-stepper" role="list" aria-label="시공 단계">
{STEPS.map((label, i) => {
const state =
i < booth.stepIndex ? 'done' : i === booth.stepIndex ? 'current' : 'todo';
return (
<div key={label} role="listitem" className={`kx-step kx-step--${state}`}>
<span className="kx-step__dot">{i + 1}</span>
<span className="kx-step__label">{label}</span>
</div>
);
})}
</div>
<div className="kx-booth__actions"> <div className="kx-booth__actions">
<Button variant="primary" leadingIcon={<IconSettings size={16} />} onClick={() => onAction('설계 스튜디오')}> <Button variant="secondary" leadingIcon={<IconDocument size={16} />} onClick={onOpen}>
</Button>
<Button variant="secondary" leadingIcon={<IconDocument size={16} />} onClick={() => onAction('규정 리포트')}>
</Button>
<Button variant="secondary" onClick={() => onAction('반입 예약')}>
</Button> </Button>
</div> </div>
</>
) : (
<div className="kx-booth__progress" aria-label={`진행률 ${booth.progressPct}%`}>
<span style={{ width: `${booth.progressPct ?? 0}%` }} />
</div>
)}
</article> </article>
); );
} }
function BidItem({ bid }: { bid: BidRow }) { function BidItem({ bid, onOpen }: { bid: MyBid; onOpen: () => void }) {
const rankCls = bid.rank === 1 ? 'kx-rankpill--1' : bid.rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low'; const rank = bid.myRank ?? 0;
const rankCls = rank === 1 ? 'kx-rankpill--1' : rank > 0 && rank <= 3 ? 'kx-rankpill--mid' : 'kx-rankpill--low';
return ( return (
<div className={`kx-cbd-bid ${bid.dim ? 'kx-cbd-bid--dim' : ''}`}> <button type="button" className="kx-cbd-bid" onClick={onOpen} style={{ width: '100%', textAlign: 'left', border: 0, background: 'transparent' }}>
<div> <div>
<p className="kx-cbd-bid__name">{bid.name}</p> <p className="kx-cbd-bid__name">{bid.auctionTitle}</p>
<p className="kx-cbd-bid__price tnum"> : {formatWon(bid.price)}</p> <p className="kx-cbd-bid__price tnum"> : {formatWon(bid.myPrice)}</p>
</div> </div>
<div className="kx-cbd-bid__right"> <div className="kx-cbd-bid__right">
<span className={`kx-rankpill ${rankCls}`}>{bid.rank}</span> <span className={`kx-rankpill ${rankCls}`}>{rank > 0 ? `${rank}` : '-'}</span>
<DdayChip dday={bid.dday} /> <DdayChip dday={bid.dday} />
</div> </div>
</div> </button>
); );
} }

View File

@ -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<CheckinSearchItemDto[] | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);
const [card, setCard] = useState<CheckinResultDto | null>(null);
const [scanError, setScanError] = useState<string | null>(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 (
<div className="kx-vis">
<EmptyState title="행사를 선택해 주세요" description="체크인을 진행할 행사가 지정되지 않았습니다." />
</div>
);
}
return (
<div className={`kx-vis kx-checkin ${kiosk ? 'is-kiosk' : ''}`}>
<header className="kx-vis__head">
<div>
<h1 className="kx-vis__title"> </h1>
<p className="kx-vis__subtitle"> QR · · </p>
</div>
<div className="kx-vis__head-actions">
{degraded && (
<span className="kx-vis__degraded" title="집계 API 미연결 — 시연 데이터">
<IconWifiOff size={14} />
</span>
)}
<button
type="button"
className={`kx-checkin__kiosk-toggle ${kiosk ? 'is-on' : ''}`}
aria-pressed={kiosk}
onClick={() => setKiosk((v) => !v)}
>
<IconGrid size={16} />
</button>
</div>
</header>
<div className="kx-checkin__grid">
{/* 좌: 스캔 존 + 수동 검색 */}
<section className="kx-card kx-checkin__scan" aria-label="배지 스캔">
<div className="kx-checkin__frame" aria-hidden="true">
<span className="kx-checkin__frame-corner tl" />
<span className="kx-checkin__frame-corner tr" />
<span className="kx-checkin__frame-corner bl" />
<span className="kx-checkin__frame-corner br" />
<ScanGlyph />
<p className="kx-checkin__frame-hint"> QR을 </p>
</div>
<form
className="kx-checkin__token"
onSubmit={(e) => {
e.preventDefault();
submitToken();
}}
>
<label htmlFor="kx-token" className="kx-checkin__label">QR / </label>
<div className="kx-checkin__token-row">
<input
id="kx-token"
className="kx-checkin__input"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="예: QR-XXXX 또는 KTX-V-0001"
autoComplete="off"
/>
<Button type="submit" disabled={checkinMut.isPending}>
{checkinMut.isPending ? '확인 중…' : '체크인'}
</Button>
</div>
</form>
<div className="kx-checkin__divider"><span> </span></div>
<div className="kx-checkin__search">
<label htmlFor="kx-search" className="kx-checkin__label"> · · </label>
<div className="kx-checkin__token-row">
<input
id="kx-search"
className="kx-checkin__input"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
runSearch();
}
}}
placeholder="검색어 2자 이상"
autoComplete="off"
/>
<Button variant="secondary" leadingIcon={<IconSearch size={16} />} onClick={runSearch}>
</Button>
</div>
{searchError && <p className="kx-checkin__err" role="alert">{searchError}</p>}
{searchResults && searchResults.length > 0 && (
<ul className="kx-checkin__results" aria-label="검색 결과">
{searchResults.map((r) => (
<li key={r.registrationId} className="kx-checkin__result">
<span className="kx-vis__avatar" aria-hidden="true">{r.nameMasked.charAt(0)}</span>
<div className="kx-checkin__result-info">
<span className="kx-checkin__result-name">
{r.nameMasked} <TypePill type={r.type} />
</span>
<span className="kx-checkin__result-meta">
{r.company ?? '-'} · {r.phoneMasked ?? '-'} · {r.badgeCode ?? '-'}
</span>
</div>
{r.checkinState === 'done' ? (
<span className="kx-checkin__done-tag"><IconCheckCircle size={14} /> </span>
) : (
<Button
variant="secondary"
onClick={() => checkinMut.mutate({ registrationId: r.registrationId })}
disabled={checkinMut.isPending}
>
</Button>
)}
</li>
))}
</ul>
)}
{searchResults && searchResults.length === 0 && !searchError && (
<p className="kx-checkin__empty"> .</p>
)}
</div>
</section>
{/* 중: 스캔 결과 카드 */}
<section className="kx-card kx-checkin__result-card" aria-label="체크인 결과">
{scanError && (
<div className="kx-checkin__scan-error" role="alert">
<p>{scanError}</p>
<Button variant="secondary" onClick={() => setScanError(null)}></Button>
</div>
)}
{!card && !scanError && (
<div className="kx-checkin__idle">
<span className="kx-checkin__pulse" aria-hidden="true" />
<p> .</p>
</div>
)}
{card && (
<div className={`kx-checkin__visitor ${card.alreadyCheckedIn ? 'is-dup' : 'is-ok'}`}>
<div className="kx-checkin__visitor-status">
{card.alreadyCheckedIn ? (
<span className="kx-checkin__badge-dup"> </span>
) : (
<span className="kx-checkin__badge-ok"><IconCheckCircle size={18} /> </span>
)}
</div>
<span className="kx-vis__avatar kx-vis__avatar--lg" aria-hidden="true">
{card.nameMasked.charAt(0)}
</span>
<h2 className="kx-checkin__visitor-name">{card.nameMasked}</h2>
<p className="kx-checkin__visitor-meta">
<TypePill type={card.type} /> · {card.company ?? '-'}
</p>
<dl className="kx-checkin__visitor-dl">
<div><dt> </dt><dd className="tnum">{card.badgeCode ?? '-'}</dd></div>
<div><dt> </dt><dd className="tnum">{card.checkinAt ?? '-'}</dd></div>
</dl>
<div className="kx-checkin__visitor-actions">
<Button block leadingIcon={<QrGlyph size={16} />}> </Button>
<Button
variant="secondary"
block
onClick={() => reissueMut.mutate(card.registrationId)}
disabled={reissueMut.isPending}
>
{reissueMut.isPending ? '재발급 중…' : '배지 재발급'}
</Button>
</div>
<p className="kx-vis__pii-note"> · ( )</p>
</div>
)}
</section>
{/* 우: 실시간 입장 집계 */}
<aside className="kx-checkin__side" aria-label="실시간 입장">
{statsQ.isLoading && !degraded && (
<div style={{ display: 'grid', gap: 12 }}>
<Skeleton height={120} radius={12} />
<Skeleton height={220} radius={12} />
</div>
)}
{hardError && (
<ErrorState message="입장 집계를 불러오지 못했습니다." onRetry={() => statsQ.refetch()} />
)}
{stats && (
<>
<div className="kx-card kx-checkin__inside">
<span className="kx-checkin__inside-label"><IconUsers size={16} /> </span>
<strong className="kx-checkin__inside-num tnum">{stats.inside.toLocaleString()}</strong>
<div className="kx-checkin__inside-sub">
<span> <b className="tnum">{stats.preRegistered.toLocaleString()}</b></span>
<span> <b className="tnum">{stats.checkedIn.toLocaleString()}</b></span>
</div>
</div>
<div className="kx-card kx-checkin__hours">
<div className="kx-card__head"><h2> </h2></div>
{chartData.length === 0 ? (
<EmptyState title="집계 없음" description="체크인이 시작되면 시간대별 추이가 표시됩니다." />
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="hour" tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => [`${(v as number).toLocaleString()}`, '체크인']} />
<Bar dataKey="count" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={28} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</>
)}
</aside>
</div>
</div>
);
}
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 <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>;
}
/** 폴백 집계(오프라인 강등 시). */
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 (
<svg width={72} height={72} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2" />
<path d="M4 12h16" className="kx-checkin__scanline" />
</svg>
);
}
/** QR 글리프(선 SVG, 장식). */
function QrGlyph({ size = 40 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 3h7v7H3V3zm2 2v3h3V5H5zm-2 9h7v7H3v-7zm2 2v3h3v-3H5zM14 3h7v7h-7V3zm2 2v3h3V5h-3zM14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z" />
</svg>
);
}

View File

@ -22,6 +22,21 @@ export function LeadScoringPage() {
const { boothId } = useParams(); const { boothId } = useParams();
const [hotOnly, setHotOnly] = useState(false); const [hotOnly, setHotOnly] = useState(false);
const [selectedId, setSelectedId] = useState<string>(''); const [selectedId, setSelectedId] = useState<string>('');
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(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({ const q = useQuery({
queryKey: ['leads', eventId, boothId ?? null], queryKey: ['leads', eventId, boothId ?? null],
@ -60,8 +75,16 @@ export function LeadScoringPage() {
{degraded && ( {degraded && (
<span className="kx-vis__degraded" title="리드 API 미연결 — 시연 데이터"> </span> <span className="kx-vis__degraded" title="리드 API 미연결 — 시연 데이터"> </span>
)} )}
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}> {exportError && (
CSV <span className="kx-vis__degraded" role="alert">{exportError}</span>
)}
<Button
variant="secondary"
leadingIcon={<IconDownload size={16} />}
onClick={handleExport}
disabled={exporting || degraded}
>
{exporting ? '내보내는 중…' : 'CSV 내보내기'}
</Button> </Button>
</div> </div>
</header> </header>
@ -169,7 +192,7 @@ export function LeadScoringPage() {
</section> </section>
{/* 리드 상세·팔로업 */} {/* 리드 상세·팔로업 */}
{selected && <LeadDetail lead={selected} />} {selected && <LeadDetail lead={selected} onExport={handleExport} exporting={exporting} />}
</div> </div>
</> </>
)} )}
@ -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; const hot = lead.score >= 80;
return ( return (
<aside className="kx-vis__lead-detail" aria-label="리드 상세"> <aside className="kx-vis__lead-detail" aria-label="리드 상세">
@ -229,7 +252,9 @@ function LeadDetail({ lead }: { lead: LeadItemDto }) {
<div className="kx-vis__lead-actions"> <div className="kx-vis__lead-actions">
<Button block>EDM </Button> <Button block>EDM </Button>
<div className="kx-vis__lead-actions-row"> <div className="kx-vis__lead-actions-row">
<Button variant="secondary" leadingIcon={<IconDownload size={16} />}>CSV </Button> <Button variant="secondary" leadingIcon={<IconDownload size={16} />} onClick={onExport} disabled={exporting}>
{exporting ? '내보내는 중…' : 'CSV 내보내기'}
</Button>
<Button variant="secondary"> </Button> <Button variant="secondary"> </Button>
</div> </div>
</div> </div>

View File

@ -9,9 +9,11 @@
* GET /api/events/{eventId}/leads?boothId&page&size ApiResponse<PageResponse<LeadItem>> * GET /api/events/{eventId}/leads?boothId&page&size ApiResponse<PageResponse<LeadItem>>
* POST /api/public/events/{eventId}/register ( P4 ) * POST /api/public/events/{eventId}/register ( P4 )
*/ */
import { api } from '../../api/client'; import { api, ApiRequestError, getAccessToken } from '../../api/client';
import type { PageResponse } from '../../api/types'; import type { PageResponse } from '../../api/types';
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
// ── SCR-30 요약 ── // ── SCR-30 요약 ──
export interface VisitorKpiDto { export interface VisitorKpiDto {
label: string; label: string;
@ -77,20 +79,95 @@ export interface LeadItemDto {
aiGenerated: boolean; aiGenerated: boolean;
} }
// ── SCR-31 현장 체크인 ──
export interface CheckinResultDto {
registrationId: string;
nameMasked: string;
type: string; // visitor|buyer|vip
company: string | null;
badgeCode: string | null;
badgeIssued: boolean;
checkinState: string; // done|waiting|cancelled
alreadyCheckedIn: boolean;
checkinAt: string | null;
}
export interface CheckinSearchItemDto {
registrationId: string;
nameMasked: string;
type: string;
company: string | null;
phoneMasked: string | null;
badgeCode: string | null;
checkinState: string;
}
export interface HourBucketDto {
hour: string;
count: number;
}
export interface CheckinStatsDto {
preRegistered: number;
checkedIn: number;
inside: number;
byHour: HourBucketDto[];
}
export interface BadgeReissueResultDto {
registrationId: string;
badgeCode: string;
badgeIssued: boolean;
}
function evPath(eventId: string): string {
return `/api/events/${encodeURIComponent(eventId)}`;
}
export const visitorApi = { export const visitorApi = {
summary: (eventId: string) => summary: (eventId: string) =>
api.get<VisitorSummaryDto>(`/api/events/${encodeURIComponent(eventId)}/visitors/summary`), api.get<VisitorSummaryDto>(`${evPath(eventId)}/visitors/summary`),
visitors: (eventId: string, page = 0, size = 20) => visitors: (eventId: string, page = 0, size = 20) =>
api.get<PageResponse<VisitorListItemDto>>( api.get<PageResponse<VisitorListItemDto>>(`${evPath(eventId)}/visitors?page=${page}&size=${size}`),
`/api/events/${encodeURIComponent(eventId)}/visitors?page=${page}&size=${size}`,
),
leads: (eventId: string, boothId?: string | null, page = 0, size = 50) => { leads: (eventId: string, boothId?: string | null, page = 0, size = 50) => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (boothId) qs.set('boothId', boothId); if (boothId) qs.set('boothId', boothId);
qs.set('page', String(page)); qs.set('page', String(page));
qs.set('size', String(size)); qs.set('size', String(size));
return api.get<PageResponse<LeadItemDto>>( return api.get<PageResponse<LeadItemDto>>(`${evPath(eventId)}/leads?${qs.toString()}`);
`/api/events/${encodeURIComponent(eventId)}/leads?${qs.toString()}`, },
);
// SCR-31 체크인 데스크 — 토큰/등록번호 체크인, 수동 검색, 실시간 집계, 배지 재발급.
checkin: (eventId: string, body: { token?: string; registrationId?: string }) =>
api.post<CheckinResultDto>(`${evPath(eventId)}/checkin`, body),
checkinSearch: (eventId: string, q: string) =>
api.get<CheckinSearchItemDto[]>(`${evPath(eventId)}/checkin/search?q=${encodeURIComponent(q)}`),
checkinStats: (eventId: string) =>
api.get<CheckinStatsDto>(`${evPath(eventId)}/checkin/stats`),
reissueBadge: (eventId: string, registrationId: string) =>
api.post<BadgeReissueResultDto>(
`${evPath(eventId)}/visitors/${encodeURIComponent(registrationId)}/badge/reissue`,
),
// SCR-32 리드 CSV 내보내기 — 봉투가 아닌 파일(byte) 응답이라 fetch 직결 후 blob 다운로드.
exportLeadsCsv: async (eventId: string, boothId?: string | null): Promise<void> => {
const qs = boothId ? `?boothId=${encodeURIComponent(boothId)}` : '';
const token = getAccessToken();
let res: Response;
try {
res = await fetch(`${API_BASE}${evPath(eventId)}/leads/export${qs}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
} catch {
throw new ApiRequestError('NETWORK', '네트워크 연결을 확인해 주세요.', 0);
}
if (!res.ok) {
throw new ApiRequestError('UNKNOWN', `CSV 내보내기에 실패했습니다. (${res.status})`, res.status);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `leads-${eventId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}, },
}; };