diff --git a/_workspace/02_frontend_progress.md b/_workspace/02_frontend_progress.md
index 880b312..c842e4c 100644
--- a/_workspace/02_frontend_progress.md
+++ b/_workspace/02_frontend_progress.md
@@ -26,7 +26,24 @@
- AI 생성 이미지는 `watermarkRequired`/`watermarkText`/`notice`를 **항상 노출**(제거 불가 `AiImage` 컴포넌트). 계약 §0-3 준수.
- 민감정보(IP·SSH·해시) 미표시. 사용자/업체는 이름·역할만.
+## 2차 이식 (C-C·C-M3~M5 프론트 — 2026-07-11 추가)
+
+완료 화면 4 + 공통 컴포넌트 2:
+- **SCR-02 주최자 대시보드** (`screens/dashboard/`): D-데이 마일스톤 타임라인(완료 체크·현재 진행·임박 펄스)·KPI 4·참가업체 테이블(상태배지)·알림 피드·S7 미니 카드. 행사 헤더는 인증 워크스페이스 실데이터, 집계부는 샘플(경계면 6 참조).
+- **SCR-06 부스 설계 스튜디오** (`screens/design/`): 스펙 아코디언 폼(기본·존·구조·자재·조명, 인라인 리깅/높이 경고)·**4샷 그리드(S1~S4)**·**Before/After(S5)**·규정 사전검증 요약 칩·컨펌 모달("시공 기준은 도면" 동의 필수). `precheck`(완성)·`render POST`(큐잉)+WebSocket 타일 교체 배선.
+- **SCR-07 유틸리티 배선 뷰** (`screens/utility/`): 기기 빌더→`quote`(완성)·배선 캔버스(**WiringCanvas** 전기적/네트워크청/급배수녹, 트렌치 그리드·범례·가정 배지)·자동 견적 패널·`order` 신청 CTA. wiring/order 501→degraded.
+- **SCR-12 시각화 갤러리** (`screens/gallery/`): 샷 필터 칩(S1~S7)·마소너리 그리드(워터마크 상시·완료/생성중/실패)·라이트박스(메타·S5는 슬라이더)·상단 고지 바 상시. `render-jobs list` 501→샘플.
+- **공통 신규**: `components/ui/CompareSlider`(reroomai §5-C 패턴 — clip-path inset + setPointerCapture + 방향키/Shift/Home·End + ARIA slider), `screens/utility/WiringCanvas`(SVG 좌표 정합 배선 렌더러 — 부스·트렌치·경로 동일 좌표계).
+- **배선**: `types.ts`에 DesignSpec/DesignPlan/Utility*/Wiring*/RenderScene·RenderJobRequest 추가, `endpoints.ts`에 `designApi(get·save·precheck)`·`utilityApi(quote·wiring·order·get)`·`renderApi(list·create)` 추가, `App.tsx` 4개 라우트.
+- **검증**: `tsc -b` EXIT 0, `vite build` EXIT 0 (253 modules, rollup win32 크래시 없음). 토큰 단일 출처 준수(하드코딩 색은 인라인 SVG 플레이스홀더 data URI 한정 — 실이미지 아님). AI 워터마크·고지 상시(AiImage·CompareSlider afterOverlay). WCAG AA(role/aria·slider·switch·focus-visible).
+
+### 추가 경계면 이슈 (kintex-qa/backend 확인 요청)
+6. **[중] 대시보드 집계 엔드포인트 부재** — SCR-02의 마일스톤·KPI(신청 128/160·확정률·유틸 완료율·위반 미해결)·참가업체 현황 테이블·알림 피드에 대응하는 계약 엔드포인트가 없다. 현재 `sampleDashboard.ts` 시연. **요청: `GET /api/events/{eventId}/dashboard`**(milestones[]·kpis[]·exhibitors[]·feed[]) 또는 개별 집계 경로.
+7. **[중] 부스 geometry(폴리곤) 조회 부재** — SCR-07 배선 캔버스가 부스 폴리곤을 6×3m로 고정 사용 중. booth 스코프 경로(design·utility)에 부스 크기/폴리곤/홀 위치가 없다. **요청: `GET .../booths/{boothId}` 또는 design/utility 응답에 `booth:{sizeM, polygon, hallPosition}`**(배선 좌표·부스 렌더 정합).
+8. **[하] Before/After "시공 전" 이미지 소스 부재** — S5는 시공 후(S1 렌더)만 계약에 있고 시공 전(빈 홀 바닥) 이미지 소스가 없다. 현재 로컬 SVG 플레이스홀더. **요청: 빈 부스 참조 이미지(render `referenceImageUrl`)를 before로 재사용 가능한지 또는 별도 before URL 제공.**
+9. **[하] 갤러리 페이지네이션·정렬 계약** — `render-jobs?shot=` 목록은 배열 직접 반환. 대량 시 정렬(최신)·페이지 파라미터 필요 여부 확인. 현재 클라이언트 정렬 없음(서버 순서 신뢰).
+
## 남은 화면 (미착수)
-- 웹: SCR-02 주최자 대시보드 · SCR-04 배치안 비교(S7) · SCR-05 참가업체 홈 · SCR-06 설계 스튜디오(4샷+Before/After) · SCR-07 배선 뷰 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세 · SCR-12 갤러리
-- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수
-- 공통 후속: 재사용 컴포넌트(Before/After CompareSlider — reroomai 패턴, 배선 오버레이 렌더러, 데이터 테이블/지브라), 실 캔버스 인터랙션(부스 드래그·리사이즈·병합), WebGL 대규모 렌더 검토.
+- 웹: SCR-04 배치안 비교(S7 — AutoLayoutDialog에 부분 구현) · SCR-05 참가업체 홈 · SCR-08 신청 요약 · SCR-09 규정 리포트 · SCR-10 승인 큐 · SCR-11 검수 상세
+- 모바일(390px): SCR-M1 현장 체크리스트 · SCR-M2 현장 검수 · SCR-06/12 모바일 반응형(읽기전용 캐러셀·2열)
+- 공통 후속: 실 캔버스 인터랙션(부스 드래그·리사이즈·병합, 배선 단말 실드래그 재계산), 위반 오버레이 좌표 연결(경계면 1), WebGL 대규모 렌더 검토.
diff --git a/_workspace/03_backend_wiring.md b/_workspace/03_backend_wiring.md
new file mode 100644
index 0000000..b20777d
--- /dev/null
+++ b/_workspace/03_backend_wiring.md
@@ -0,0 +1,85 @@
+# 킨텍스 백엔드 — M2~M5 매퍼 배선 + 계약 확장 (Phase C, D-4)
+
+> 작성: kintex-backend-dev · 근거: `_workspace/01_backend_contracts.md`·`_workspace/02_db_schema.md`·`_workspace/02_frontend_progress.md`(계약 공백 5건)·IMPLEMENTATION_BACKLOG C-M2~C-M5
+> 대상: **frontend·qa 대조**. 이번 회차는 db-engineer가 완성한 MyBatis 매퍼에 ServiceImpl을 배선해 **M2~M5·인증의 501(NOT_IMPLEMENTED)을 200으로 전환**하고, 프론트가 지적한 계약 공백을 **additive**(하위호환)로 보강한다. 스키마 마이그레이션·2FA·프론트·build.gradle 미변경. 커밋 없음(파일만).
+
+---
+
+## 1. 501 → 200 전환된 엔드포인트
+
+| 모듈 | 메서드 | 엔드포인트 | 배선 요지 |
+|---|---|---|---|
+| 인증 | login | `POST /api/auth/login` | `UserMapper.findAuthByEmail` + BCrypt 대조 + `findEventRoles`→토큰·워크스페이스 |
+| 인증 | listWorkspaces | `GET /api/auth/workspaces` | `findEventRoles(userId)`→WorkspaceDto[] |
+| 인증 | acceptInvite | `POST /api/auth/accept-invite` | 등록업체 검증(`findCompanyRegistered`)→토큰 재발급. ★초대코드 매핑은 후속(아래 TODO) |
+| M2 | getLayout | `GET …/halls/{hallId}/layout` | `findLayout`+`findBooths`(GeoJSON 파싱)+`HallMapper`+검증 요약 |
+| M2 | saveLayout | `PUT …/layout` | 폴리곤→WKT `upsertLayout`+`replaceBooths`, 저장 후 규정 재검증 반영 |
+| M2 | validate | `POST …/layout/validate` | `minAisleWidth`·`countExitsBlocked`+부스 최댓값→룰엔진 M2 |
+| M2 | autoGenerate | `POST …/layout/auto-generate` | 제약 기반 그리드 패킹으로 **정확히 optionCount(기본 3)안** + S7 발행 |
+| M2 | applyOption | `POST …/layout/apply-option` **(신규)** | 선택/병합 부스열→새 버전 저장+규정 재검증 |
+| M3 | getDesign | `GET …/booths/{boothId}/design` | `findDesign`+spec(jsonb) 역직렬화 |
+| M3 | saveDesign | `PUT …/design` | spec 직렬화 `upsertDesign`(버전 next), 최신본 반환 |
+| M4 | computeWiring | `POST …/utility/wiring` | `isAssumedTrench`+단말별 `shortestPath`(LineString 파싱)·색상 규약 |
+| M4 | submitOrder | `POST …/utility/order` | `quote`+`UtilityOrderMapper.insertOrder`(영속)+위치표시도 URL·마감/릴레이 고지 |
+| M4 | getOrder | `GET …/utility` | `UtilityOrderMapper.findOrder`(부스 최신) |
+| M5 | listByBooth | `GET …/render-jobs` | `RenderJobMapper.findByBooth`→갤러리 목록(워터마크 항상 포함) |
+| M5 | publish(보강) | `POST …/render` | 쿼터 정본을 `countSucceededByEvent`(DB DONE)로 전환 + `insertJob` 내구 이력 |
+| M5 | callback(보강) | `POST /api/internal/render/callback` | `updateStatus` 내구 갱신(쿼터는 DONE 집계에서 자동) |
+
+---
+
+## 2. 계약 확장 (additive · 하위호환 · 프론트 gap 해소)
+
+모두 `@JsonInclude(NON_NULL)`로 **기존 필드 불변, 신규 nullable 필드만 추가**.
+
+### 2-1. gap #1 — 위반 오버레이 좌표 (`rules.Violation`)
+- 추가 필드: `boothId`(String, nullable) · `anchor`([x,y] 미터, nullable).
+- **부스 단위 규칙(HEIGHT_MAX·FLOOR_LOAD)** 위반에 한해 해당 부스 id + 폴리곤 중심 앵커를 부여. 레이아웃 단위(AISLE_WIDTH_MIN·EXIT_ACCESS)는 null(캔버스에서 목록 핀만).
+- 프론트: `violations[].anchor` 존재 시 캔버스 핀 위치로 사용, 없으면 기존처럼 목록 핀만.
+
+```json
+{ "pin":1, "code":"HEIGHT_MAX", "severity":"block", "measured":"측정 5.4m",
+ "boothId":"b-102", "anchor":[3.0,1.5] }
+```
+
+### 2-2. gap #2 — 홀 치수·트렌치 (`m2.dto.LayoutDto.hall` = `HallInfo`)
+- `LayoutDto`에 `hall` 필드 추가(신규 `HallInfo`). 캔버스 하드코딩(126×90m·트렌치 그리드) 제거 근거.
+- `HallInfo`: `hallId·label·dimsM[w,d]·ceilingM·targetBoothCount·floorLoadTPerM2·assumedTrench·trenches[]·exits[]`.
+- `HallInfo.Trench`: `{trenchId,x,y,assumed,supplies[power|water|air|network|gas]}` · `HallInfo.Exit`: `{x,y,clearanceM}`.
+- 원천: 신규 `HallMapper`(hall·trench·hall_exit read-only). `assumedTrench=true`면 SCR-07 "가정 트렌치" 배지(PLANNING R4).
+
+```json
+"hall": { "hallId":"H7","label":"제2전시장 홀7","dimsM":[126,90],"ceilingM":12,
+ "targetBoothCount":510,"floorLoadTPerM2":5.0,"assumedTrench":true,
+ "trenches":[{"trenchId":"H7-T-3-3","x":3,"y":3,"assumed":true,"supplies":["power","water","air","network","gas"]}],
+ "exits":[{"x":63,"y":0,"clearanceM":3.0}] }
+```
+
+### 2-3. gap #3 — 자동배치 안의 부스 + 적용 경로 (`AutoLayoutOption.booths` + apply-option)
+- `AutoLayoutOption`에 `booths`(BoothDto[]) 추가 — "이 안으로 편집 시작"/병합 소스.
+- 신규 `POST …/layout/apply-option` (ORGANIZER): body `ApplyOptionRequest {name, booths[]}` → 새 버전 저장 + 규정 재검증된 `LayoutDto`.
+- **3안 흐름 마감**: auto-generate(3안+각 booths) → 클라 선택/구역 병합(booths 합성) → apply-option(영속+재검증). 단일 안 적용도 동일 엔드포인트.
+
+---
+
+## 3. backend 추가 매퍼 (db-engineer 계약 §3 밖 — 기존 테이블 read/write, 마이그레이션 불변)
+> db-engineer 인수·정본화 요청. 모두 V2~V5 기존 테이블만 사용.
+
+| 매퍼 | 메서드 | 테이블 |
+|---|---|---|
+| `m2.mapper.HallMapper` | findHall·findTrenches·findExits | hall·trench·hall_exit (read-only) |
+| `m4.mapper.UtilityOrderMapper` | insertOrder·findOrder | utility_order (V3) |
+| `auth.mapper.UserMapper`(+1) | findCompanyRegistered | company (read-only) |
+
+---
+
+## 4. 남은 TODO (경계·후속)
+- **초대코드→행사/부스/역할 매핑**: invite 테이블·매퍼 미제공 → acceptInvite는 등록업체 게이트 + 현재 역할 재발급까지만. 역할 upsert는 매핑 매퍼 인수 시 배선(db-engineer).
+- **위치표시도 PDF(M4-3)**: submitOrder는 신청 영속 + 자산 URL(`…/utility/location-diagram`)·고지 반환. 실제 PDF 렌더는 문서 큐(후속). 현재 해당 GET 자산은 미구현(URL만).
+- **wiring 지오메트리 영속**: computeWiring은 산출·반환만. utility_order.wiring(MultiLineString) 저장은 후속.
+- **자동배치 최적화**: 현재 결정적 그리드 패킹(3안=통로폭/규격 변주, 무위반 골격). 제약 솔버 고도화는 후속(흐름은 성립).
+- **트렌치 실측(R4)**: 가정 그리드 유지(is_assumed=true). CAD 추출 교체 시 HallInfo.assumedTrench=false 자동 반영.
+
+## 5. 검증
+- `./gradlew compileJava` · `./gradlew build -x test` 통과. 매퍼 id/namespace 정합 확인. PostgreSQL 미기동으로 런타임 왕복은 dev 배포 후.
+- 보안 불변 유지: password_hash/otp 응답 제외, 스택트레이스 미노출(요약 메시지), AI 이미지 워터마크·고지 항상 포함, 워커 에러 요약화.
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java
index 5fe3d22..5acc3e2 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/AuthServiceImpl.java
@@ -6,53 +6,135 @@ import com.zioinfo.kintex.auth.dto.LoginResponse;
import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
/**
- * 인증 서비스 골격. JWT 발급/검증 경로({@link JwtService})는 완성되어 있으나,
- * 사용자·멤버십 조회는 {@link UserMapper} XML(kintex-db-engineer) 구현 대기 → 501 반환.
+ * 인증 서비스 — UserMapper(인증행·행사 역할) 배선 완료. 로그인·워크스페이스·초대 수락을 처리한다.
*
- *
보안: 비밀번호 검증은 저장된 해시와 대조(BCrypt 등)하며, 응답에 해시·내부 식별자 미노출.
+ *
보안(계약 §0-3): 비밀번호는 BCrypt 해시와 대조({@link PasswordEncoder})하며, 해시·내부 식별자는 응답에 미노출.
+ * 실패 메시지는 이메일/비밀번호를 구분하지 않는 일반화 문구만 반환한다.
+ *
+ *
2차 인증(OTP/TOTP)·로그인 실패 잠금은 common-dev(B-1) 레인이 별도로 얹으며 본 서비스는 건드리지 않는다.
*/
@Service
public class AuthServiceImpl implements AuthService {
private final JwtService jwtService;
private final UserMapper userMapper;
+ private final PasswordEncoder passwordEncoder;
- public AuthServiceImpl(JwtService jwtService, UserMapper userMapper) {
+ public AuthServiceImpl(JwtService jwtService, UserMapper userMapper, PasswordEncoder passwordEncoder) {
this.jwtService = jwtService;
this.userMapper = userMapper;
+ this.passwordEncoder = passwordEncoder;
}
@Override
public LoginResponse login(LoginRequest request) {
- // TODO(kintex-db-engineer): UserMapper.findAuthByEmail + 비밀번호 해시 검증 후
- // JwtService.issue(userId, name, eventRoles, hallManager) 로 토큰 발급.
- // 구현 예시 흐름:
- // var row = userMapper.findAuthByEmail(request.email());
- // if (row == null || !passwordEncoder.matches(request.password(), (String) row.get("passwordHash")))
- // throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
- // ... eventRoles = userMapper.findEventRoles(userId) 조립 ...
- // token = jwtService.issue(...);
- throw ApiException.notImplemented("로그인(사용자 인증 조회)");
+ Map row = userMapper.findAuthByEmail(request.email());
+ String hash = row == null ? null : str(row.get("passwordHash"));
+ if (row == null || hash == null || !passwordEncoder.matches(request.password(), hash)) {
+ // 이메일/비밀번호 구분 없이 일반화(계정 열거 방지).
+ throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
+ }
+ String userId = str(row.get("userId"));
+ String displayName = str(row.get("displayName"));
+ boolean hallManager = Boolean.TRUE.equals(bool(row.get("hallManager")));
+ return buildLoginResponse(userId, displayName, hallManager);
}
@Override
public List listWorkspaces(KintexPrincipal principal) {
- // TODO(kintex-db-engineer): principal.eventRoles 기반 행사 카드(행사명·기간·홀·D-데이) 조회.
- throw ApiException.notImplemented("워크스페이스 목록 조회");
+ return loadWorkspaces(principal.userId());
}
@Override
public LoginResponse acceptInvite(KintexPrincipal principal, AcceptInviteRequest request) {
- // TODO(kintex-db-engineer):
- // 1) inviteCode 로 초대(행사·부스·역할) 조회
- // 2) 역할이 CONTRACTOR면 companyRegistrationNo 로 등록업체 검증 —
- // 미등록 시 throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY)
- // 3) 멤버십 upsert 후 갱신된 eventRoles 로 새 토큰 발급(jwtService.issue)
- throw ApiException.notImplemented("초대 코드 수락(등록업체 검증 포함)");
+ // 장치업체(CONTRACTOR) 초대 수락 시 사업자번호 → 킨텍스 등록업체 검증(미등록 차단, 계약 §M7).
+ if (request.companyRegistrationNo() != null && !request.companyRegistrationNo().isBlank()) {
+ Boolean registered = userMapper.findCompanyRegistered(request.companyRegistrationNo());
+ if (!Boolean.TRUE.equals(registered)) {
+ throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY,
+ "킨텍스 등록업체만 참여할 수 있습니다.");
+ }
+ }
+ // 멤버십 반영 후 갱신된 역할로 새 토큰 재발급(현재 역할 기준 재조립).
+ // ★초대코드→행사/부스/역할 매핑(invite 테이블)은 db-engineer 인수 대기 — 매핑 매퍼 제공 시 역할 upsert 배선.
+ return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager());
+ }
+
+ /** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */
+ private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) {
+ List