diff --git a/CLAUDE.md b/CLAUDE.md index 322f8c7..0447d37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ GUARDiA 표준 프레임워크(Spring Boot 3.5 + React 19 + MyBatis + PostgreSQL **에이전트(전문 20 + 범용):** 거버넌스 kintex-pm·dev-pm·pmo / 아키텍트 kintex-aa·sa·ta·da·na / 공통 kintex-common-dev / 코어 kintex-backend-dev·frontend-dev·db-engineer / 도메인 kintex-bidding-dev·visitor-dev·cms-dev·bi-dev·admin-dev / AI·시각화 kintex-ai-dev·visualizer / 품질·배포 kintex-qa·devops-dev + planner·designer·reviewer -**선행 게이트:** **G2 해소** — 개발 `kintex.zioinfo.kr`(등록 완료 → 서버 101.79.17.164, GUARDiA 공유 인프라 재사용) / 운영 `kintex.wise.ai.kr`. nginx vhost·포트는 devops 배정. **잔여: G1** 나노바나나(Gemini) 외부 호출 승인(PLANNING R12) — M5 실호출 전. +**선행 게이트: G1·G2 모두 해소** — G1 나노바나나 Gemini 라이브(워커 env `GEMINI_API_KEY`+`NANOBANANA_LIVE=1`, `gemini-3.1-flash-image-preview`, 키 마스킹·미커밋). G2 개발 **`kintex.zioinfo.co.kr`**(DNS 해소 → 101.79.17.164, **포트 8021**, PostGIS+Redis+Flyway) / 운영 `kintex.wise.ai.kr`(후속). CI/CD 라이브(deploy_server kintex 블록·Gitea webhook #47·자동배포 E2E 검증). **확보 자산:** `docs/assets/floorplans/` — 홀별 평면도 JPG 15장 + CAD(제1전시장 "평면,트렌치.dwg" 포함) → PLANNING R4(트렌치·CAD) 해소. 평면도 입력 포맷 = **CAD(DWG) + JPG**. CAD zip은 gitignore(로컬 보존). diff --git a/docs/design.md b/docs/design.md index bcaf8c2..bfe61c3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md) -> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-11 · 버전: v1.1 +> 작성: 디자인 에이전트(designer) · 작성일: 2026-07-11 · 버전: v1.2 > 근거 문서: `docs/PLANNING.md` v1.0 (모듈 맵 M1~M9, P0 기능, 나노바나나 표준 샷 세트 S1~S7) · Stitch 산출물 `stitch_kintex_ai_system_architect/`(18화면 + DESIGN.md 3변형) 학습·정합 > **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다. > 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어. @@ -112,10 +112,14 @@ └─ 현장 검수 (모바일, SCR-M2) ``` -### 2-2. 내비게이션 +### 2-2. 내비게이션 (MDI 셸 기준 — v1.2) -- **웹**: 좌측 고정 사이드바(240px) — 상단 행사 전환 드롭다운(행사명+기간+홀), 역할별 메뉴, 하단 사용자 메뉴. 상단 바에는 브레드크럼 + 알림 벨(마감·승인 이벤트) + "D-31" 행사 카운트다운 칩 상시 노출. -- **모바일**: 하단 탭 바 4개 — 홈 / 내 부스(또는 승인 큐) / 알림 / 더보기. 캔버스 화면은 뷰어 모드. +- **웹(업무 포털)**: **MDI(다중 문서 인터페이스) 셸**을 표준 AppShell로 채택한다. 좌측 고정 사이드바(240px) + **상단 문서 탭바** + 중앙 활성 문서 영역의 3영역 구조다. + - 좌측 사이드바(240px): 상단 행사 전환 드롭다운(행사명+기간+홀), 역할별 메뉴, 하단 사용자 메뉴. **메뉴 클릭은 라우트 이동이 아니라 "문서 탭 열기/활성 전환"**이다. + - 상단: 탭바 위에 얇은 유틸리티 바 — 브레드크럼(활성 탭 경로) + 알림 벨(마감·승인 이벤트) + "D-31" 행사 카운트다운 칩 상시 노출. + - **단일 라우트 SPA → 탭 호스트로 전환**: 기존 "한 번에 한 화면" 라우팅을 폐기하고, 여러 문서(대시보드·배치 에디터·설계 스튜디오·배선 뷰·갤러리·승인 큐·검수 상세 등)를 탭으로 동시에 열어 상태를 보존한 채 전환한다. 상세 표준은 **§2.7 MDI 셸**을 따른다. +- **웹(공개/관람객 사이트)**: **MDI 비적용.** 공개 홍보 사이트(`www/expo.`)·관람객 사전등록은 SEO·딥링크·공유가 목적이므로 전통적 페이지 내비(헤더 GNB + 단일 라우트)를 유지한다. +- **모바일**: 하단 탭 바 4개 — 홈 / 내 부스(또는 승인 큐) / 알림 / 더보기. 화면 전환은 네이티브 스택 내비(모바일은 MDI 미적용 — 단일 화면 포커스). 캔버스 화면은 뷰어 모드. --- @@ -157,6 +161,112 @@ --- +## 2.7 MDI 셸 (다중 문서 인터페이스) — AppShell 표준 (v1.2 신설) + +> **엔터프라이즈 전시관리 실무자는 여러 문서(부스·행사·도면·승인건)를 동시에 열어 오가며 작업한다.** 배치 에디터에서 위반을 확인하다가 승인 큐로, 다시 설계 스튜디오로 이동해도 각 화면의 미저장 편집·스크롤·폼 값이 유지돼야 한다. 따라서 업무 포털의 AppShell을 **단일 라우트 SPA → MDI 탭 호스트**로 전환한다. 이 절이 `kintex-frontend-dev`의 AppShell 전환 기준(단일 출처)이다. 모든 치수·색상은 §1 디자인 토큰을 사용한다. + +### 2.7-1. 셸 레이아웃 + +3영역 고정 그리드(웹 1440px 기준): + +``` +┌────────────────────────────────────────────────────────────┐ +│ [유틸리티 바] 브레드크럼(활성 탭) · 알림벨 · D-데이 칩 · 사용자 │ 높이 48px +├──────────┬─────────────────────────────────────────────────┤ +│ │ [탭바] ▸ 대시보드 │ 부스 에디터 ● │ 승인 큐 │ + │ 높이 40px +│ 사이드바 ├─────────────────────────────────────────────────┤ +│ 240px │ │ +│ (메뉴) │ DocumentHost (활성 탭 문서) │ +│ │ 비활성 탭은 언마운트하지 않고 │ +│ │ display:none 로 상태 보존 │ +│ │ │ +└──────────┴─────────────────────────────────────────────────┘ +``` + +- **사이드바(240px)**: 기존 역할별 메뉴 유지. 항목 클릭 → 해당 문서 탭 열기(중복 방지 시 기존 탭 활성). 활성 문서에 해당하는 메뉴 항목은 `primary-100 #D9EAF7` 배경으로 하이라이트. +- **탭바(높이 40px)**: 사이드바 우측 상단, 유틸리티 바 아래 전폭. 문서 탭이 좌→우로 나열되고, 우측 끝 `+`(빠른 열기 팔레트). 탭바 배경 `neutral-050 #F9FAFB`, 하단 1px 테두리 `neutral-200`. +- **DocumentHost(중앙)**: 활성 탭의 문서만 시각적으로 표시. **비활성 탭은 DOM에서 언마운트하지 않고 `display:none`(또는 offscreen)으로 유지**해 스크롤·폼·에디터 상태를 보존한다(§2.7-3). 배경은 화면별(대시보드=`neutral-050`, 에디터/배선 뷰 캔버스 영역=`canvas-bg #1C2536`). +- **엘리베이션**: 탭바·문서 경계는 그림자 대신 §1-4 규칙대로 1px 테두리로 구분. 활성 탭만 문서 영역과 같은 흰색 서피스로 "연결"된 느낌(활성 탭 하단 테두리 제거). + +### 2.7-2. 탭(Tab) 시각·상호작용 규격 + +- **탭 구성**: `[아이콘] 문서 라벨 [dirty ●] [x]`. 라벨 최대폭 200px(초과 시 말줄임 `…`, 툴팁 전체 표시). 상태 배지가 있는 문서(승인건 등)는 라벨 앞에 §1-4 StatusBadge 소형 점. +- **활성 탭**: 흰색 배경 + 상단 2px `primary-600 #0066B3` 인디케이터 + `neutral-900` 텍스트. **비활성 탭**: `neutral-050` 배경 + `neutral-500` 텍스트, hover 시 `#F1F5F9`. +- **dirty(미저장) 표시**: 미저장 변경이 있으면 `x` 자리에 채워진 점 **●**(`warning #B45309`) 표시, hover 시 `x`로 전환. **닫기 시 미저장이면 확인 다이얼로그**("저장하지 않은 변경이 있습니다 — 닫으시겠습니까? / 저장 후 닫기 · 저장 안 함 · 취소"). +- **동작 규격**: + | 동작 | 트리거 | 결과 | + |---|---|---| + | 열기 | 사이드바 메뉴 · 화면 내 링크 · 딥링크 URL 진입 | 새 탭 생성 후 활성. **중복 방지**: 같은 문서키(§2.7-6)가 이미 열려 있으면 새로 만들지 않고 기존 탭을 활성·포커스 | + | 활성 전환 | 탭 클릭 · Ctrl+Tab | URL 동기화(§2.7-4), DocumentHost가 해당 문서 표시 | + | 닫기 | `x` 클릭 · 미들클릭 · Ctrl+W | dirty면 확인 다이얼로그. 활성 탭 닫으면 오른쪽(없으면 왼쪽) 인접 탭 활성 | + | 재정렬 | 탭 드래그(HTML5 DnD/포인터) | 순서 변경, 세션 저장 반영 | + | 컨텍스트 메뉴 | 우클릭 | 닫기 · 다른 탭 모두 닫기 · 오른쪽 탭 모두 닫기 · 새 창으로 분리(딥링크 복제) | +- **최대 탭 수**: 기본 **12개**. 초과 시 새 탭 열기 요청이 오면 토스트("탭이 12개를 초과했습니다 — 사용하지 않는 탭을 닫아주세요") + LRU(가장 오래 비활성·非dirty) 탭 닫기 제안. dirty 탭은 자동 닫기 대상에서 제외. +- **오버플로우**: 탭 총 폭이 탭바를 넘으면 ① 탭 폭 축소(최소 120px)까지 우선 → ② 그 이상은 **가로 스크롤**(마우스 휠·좌우 셰브론) + 우측 **오버플로우 드롭다운(▾)**(전체 탭 목록, dirty·상태 배지 포함, 검색 가능). 활성 탭은 항상 보이도록 자동 스크롤 인. +- **핀 고정(선택)**: 대시보드/승인 큐 등 홈 성격 문서는 핀 고정 가능(아이콘만·닫기 숨김·항상 최좌측). 역할 진입 시 홈 탭 1개는 기본 핀 고정으로 자동 오픈. + +### 2.7-3. 탭별 상태 보존 + +- **보존 대상**: ① 에디터 미저장 편집(부스 폴리곤 위치·설계 스펙 폼) ② 스크롤 위치 ③ 폼 입력·필터·선택 행 ④ 캔버스 줌·팬·레이어 토글. +- **구현 원칙**: 비활성 탭은 **언마운트 금지**(`display:none` 유지) — React 컴포넌트 인스턴스와 로컬/스토어 상태를 살려둔다. 무거운 캔버스(SCR-03 배치 에디터, SCR-07 배선 뷰)는 활성일 때만 렌더 루프·WebGL 컨텍스트를 활성화하고 비활성 시 일시정지(freeze)하되 상태 객체는 메모리에 유지. +- **메모리 가드**: 최대 탭 수(12) + freeze로 상한을 두되, 캔버스 문서는 동시 3개 초과 시 가장 오래된 비활성 캔버스의 WebGL 컨텍스트만 파기(상태 스냅샷은 유지 → 재활성 시 복원). 이는 §1-5 "캔버스 편집=데스크톱 전용" 제약과 정합. +- **dirty 판정**: 각 문서는 자신의 dirty 여부를 탭 스토어에 보고한다(저장 성공 시 false). 브라우저 이탈(`beforeunload`) 시 dirty 탭이 하나라도 있으면 네이티브 확인 경고. + +### 2.7-4. 세션 복원 & 딥링크 ↔ 탭 URL 동기화 + +- **각 탭은 라우트를 가진다.** 활성 탭 전환 시 브라우저 URL이 그 문서의 딥링크로 동기화(`history.replaceState`), 탭 열기/닫기는 push. 예: + - `/o/{eventId}/dashboard` → 주최자 대시보드(SCR-02) + - `/o/{eventId}/floorplan/{layoutId}` → 부스 배치 에디터(SCR-03) + - `/e/{eventId}/booth/{boothId}/studio` → 설계 스튜디오(SCR-06) + - `/e/{eventId}/booth/{boothId}/utility` → 배선 뷰(SCR-07) + - `/m/{eventId}/approvals` · `/m/{eventId}/review/{submissionId}` → 승인 큐·검수 상세(SCR-10/11) + - `/gallery/{scope}` → 시각화 갤러리(SCR-12) +- **딥링크 진입**: 외부에서 위 URL로 진입하면 셸이 부팅되며 **해당 문서 탭 1개를 열고 활성화**(중복 방지 규칙 적용). 알림·메일 링크는 이 경로를 사용. +- **세션 복원**: 열린 탭 목록(문서키·라우트·순서·핀·활성 인덱스)을 `localStorage`(키 `kintex.mdi.{role}.{eventId}`)에 디바운스 저장. 새로고침·재접속 시 복원하되 **미저장 편집 내용 자체는 복원 대상 아님**(탭은 되살아나지만 서버에 저장되지 않은 편집은 "이어서 편집" 초안 API가 있을 때만). 복원 실패 시 홈 탭만 열고 토스트. +- **행사 전환**: 사이드바에서 행사(eventId)를 바꾸면 현재 행사 탭 세션을 저장하고 대상 행사의 세션을 로드(행사별로 탭 셋 분리). dirty 탭이 있으면 전환 전 확인. + +### 2.7-5. 단축키 & 접근성 + +- **단축키**: `Ctrl+W` 활성 탭 닫기 · `Ctrl+Tab` / `Ctrl+Shift+Tab` 다음/이전 탭 · `Ctrl+1~9` N번째 탭 · `Ctrl+클릭`(사이드바 메뉴/링크) 백그라운드 새 탭 · `Ctrl+Shift+T` 마지막 닫은 탭 복원 · `Alt+←/→` 탭 재정렬. dirty 탭 `Ctrl+W`는 확인 다이얼로그를 거친다. 텍스트 입력·에디터 포커스 중에는 편집 단축키가 우선(충돌 방지). +- **접근성(ARIA)**: 탭바는 `role="tablist"`(수평, `aria-orientation="horizontal"`), 각 탭 `role="tab"`(`aria-selected`, `aria-controls`가 문서 패널 id 참조), 문서 영역 `role="tabpanel"`(`aria-labelledby`가 탭 id). **키보드 로빙 탭 인덱스**: 좌우 화살표로 탭 간 이동, Enter/Space로 활성, Delete로 닫기(dirty 확인 경유). 닫기 `x`는 `aria-label="{문서명} 탭 닫기"`. dirty ● 는 스크린리더용 `aria-label="저장되지 않음"` 텍스트 병기. 포커스 링은 `primary-600` 2px, 명도 대비 WCAG AA 이상. + +### 2.7-6. 역할별 적용 + +| 역할 | 포털 | MDI | 비고 | +|---|---|---|---| +| 관리자(Admin) | `admin.` 백오피스 | **적용** | 사용자·RBAC·감사로그·룰셋 등 다수 관리 문서 병행 | +| 주최자(Organizer) | `organizer.` 콘솔 | **적용** | 대시보드·배치 에디터·비교·참가업체 관리 병행(SCR-02·03·04) | +| 참가업체(Exhibitor) | `exhibitor.` 포털 | **적용** | 부스 홈·설계 스튜디오·배선 뷰·갤러리 병행(SCR-05·06·07·08·12) | +| 장치·시공업체(Contractor) | 참가업체 포털 공유 | **적용** | 수주 부스별 설계 스튜디오·규정 리포트 다중 오픈(SCR-06·09) | +| 홀매니저(Hall Manager) | `ops.` 운영 | **적용** | 승인 큐 + 검수 상세 여러 건 동시 검토(SCR-10·11) | +| 관람객/일반 대중(Visitor/Public) | 공개 사이트 `www/expo.` | **비적용** | SEO·공유·딥링크 목적 전통 페이지 내비 유지 | + +- **모바일 전 역할**: MDI 비적용(단일 화면 포커스 + 네이티브 스택 내비). 현장·조회·승인 시나리오는 한 화면 집중이 안전(오조작 방지). +- 6개 역할 포털은 각자 독립 번들이지만 **동일한 MDI 셸 컴포넌트·탭 스토어를 공유 라이브러리로 상속**한다(§2.7-7). 역할별 차이는 사이드바 메뉴·허용 문서 라우트·홈 핀 탭뿐이다. + +### 2.7-7. 컴포넌트 & 상태 스토어 표준 + +프론트 공유 라이브러리에 다음 컴포넌트·스토어를 신설한다(§1 토큰 사용, 로직은 kintex-frontend-dev 소관 — 여기서는 계약만 정의). + +- **``**: 사이드바 + `` + `` + `` 조립. 역할·eventId를 props로 받아 메뉴·세션키·허용 라우트를 주입. +- **``**: 탭 목록 렌더·드래그 재정렬·오버플로우(스크롤+드롭다운)·`+` 팔레트. `role="tablist"`. props: `tabs`, `activeId`, `onActivate`, `onClose`, `onReorder`, `maxTabs=12`. +- **``**: 단일 탭 프레젠테이션(아이콘·라벨·dirty ●·닫기). `role="tab"`. 상태: 활성/비활성/hover/dirty/pinned. +- **``**: 열린 문서들을 keep-alive로 렌더(활성만 표시, 비활성 `display:none`). 문서키→라우트→컴포넌트 매핑(문서 레지스트리)을 통해 lazy 로드. `role="tabpanel"`. +- **탭 상태 스토어(`useMdiStore`)** — 문서 인스턴스의 단일 진실: + - `DocumentInstance { id, docKey, route, title, icon, params, dirty, pinned, status?, createdAt, lastActiveAt }` + - `docKey` = 문서 정체성(중복 방지 키). 예: `floorplan:{layoutId}`, `booth-studio:{boothId}`, `review:{submissionId}`. **같은 docKey는 탭 1개**로 강제. + - `params`: 문서 복원용 파라미터(스크롤·필터 등 경량 상태 스냅샷은 문서가 자체 스토어로 관리, 스토어에는 식별·라우팅·dirty만 보관해 비대화 방지). + - 액션: `openTab(docKey, route, meta)` / `activateTab(id)` / `closeTab(id, {force})` / `reorder(from,to)` / `setDirty(id,bool)` / `pinTab(id)` / `restoreSession()` / `persistSession()`. + - **직렬화 규칙**: `localStorage`에는 `{id, docKey, route, title, icon, pinned, order, activeId}`만 저장(dirty·휘발 상태 제외). 복원 시 문서는 라우트로 재구성. + +**MDI 셸 Stitch 프롬프트** (셸 크롬 생성용 — 문서 콘텐츠는 각 SCR 프롬프트로 별도 생성): + +``` +An enterprise MDI (multiple document interface) application shell for "KINTEX AI 전시관리", a Korean B2B exhibition management web app. Desktop 1440px, light theme, primary blue #0066B3, backgrounds #F9FAFB, 1px borders #E4E7EC, Korean sans-serif (Pretendard style). All UI text in Korean. Layout: a slim 48px top utility bar with a breadcrumb "부스 배치 에디터 · 배치안 B", a notification bell, a countdown chip "개장 D-31", and a user avatar on the right. A fixed left sidebar 240px with an event switcher dropdown "2026 스마트팩토리 코리아 ▾" and a vertical menu ("대시보드, 플로어플랜 스튜디오, 참가업체 관리, 승인 큐, 시각화 갤러리"), the "플로어플랜 스튜디오" item highlighted with a light blue #D9EAF7 background. To the right of the sidebar, below the utility bar, a horizontal document TAB BAR 40px tall on #F9FAFB with a bottom border: several browser-style tabs in a row — a pinned home tab with only a dashboard icon, then "부스 배치 에디터" as the ACTIVE tab (white background, 2px blue #0066B3 top indicator, connected to the content below, with a small close × ), then "장치 도면 검수" tab showing an amber unsaved dot ● instead of ×, then "승인 큐" tab, then a truncated tab "유틸리티 배선…", and a right-side overflow chevron ▾ and a small "+" button. Below the tab bar, a large central document area showing the active document (a dark navy #1C2536 floor-plan editor canvas with small blue booth rectangles), so it's clear each tab hosts an independent document. Clean, dense, professional enterprise tool aesthetic — emphasize the browser-like tab strip and the sidebar-to-tab relationship. +``` + +--- + ## 3. 화면별 스펙 > P0 모듈(M2·M3·M4·M5) 중심 총 14개 화면(웹 12 + 모바일 2). 각 Stitch 프롬프트는 화면 1개를 1회 생성하는 기준으로 작성했다. @@ -430,3 +540,4 @@ A mobile field inspection screen for a KINTEX hall manager verifying booth const | v1.0 | 2026-07-11 | designer | 최초 작성 — PLANNING.md v1.0 기반. 디자인 시스템·IA·P0 중심 14화면(웹 12 + 모바일 2) Stitch 프롬프트 포함. 다크모드는 Phase 1 미지원(캔버스 다크 서피스만) 결정 | | v1.0.1 | 2026-07-11 | reviewer 반영 | 화면 수 표기 정정(13→14). 잔여 지적사항은 docs/BACKLOG.md 참조 | | v1.1 | 2026-07-11 | designer | **Stitch 산출물 학습·정합화.** `stitch_kintex_ai_system_architect/` 18화면 code.html + DESIGN.md 3변형(Intelligence·Nexus·Precision) 학습. ①§1-2 컬러: Precision Enterprise AI 변형이 우리 토큰(#0066B3·#1C2536·#6D4AFF·#D92D20·#F9FAFB·#101828·StatusBadge 진행) 실증 확인 — 우리 팔레트를 단일 출처로 유지, #0059A6·#00427D는 짙은 대체 톤으로만 허용, ai-surface #F5F3FF AI 카드 배경 채택. ②§1-3 타이포: Pretendard 권위 유지, Inter를 라틴/tabular 보조로 허용(Stitch가 Hanken Grotesk/Inter 채택), 행 높이 44/48px 권위값 유지. ③§1-4 라운드(4/8px·pill)·엘리베이션(톤레이어+1px 테두리+AI 좌측 보라 액센트)·그리드(12컬럼·1440px·4px 베이스)·지브라 테이블 토큰 신설(이전 누락분). ④§2.5 Stitch 화면↔SCR 매핑표 신설(14화면). ⑤§2.6 범위 게이트 — proposal.md 및 범위 밖 4화면(business_intelligence·hall_operations·exhibition_schedule·admin_dashboard)을 P2 후보로 분류(PLANNING이 범위 권위, designer 임의 승격 금지). PLANNING.md·타 문서 무수정 | +| v1.2 | 2026-07-11 | designer | **MDI(다중 문서 인터페이스) 셸 표준 신설.** 업무 포털 AppShell을 단일 라우트 SPA → 탭 호스트로 전환하는 표준을 §2.7로 정의(kintex-frontend-dev AppShell 전환 기준). ①§2-2 내비게이션을 MDI 기준으로 갱신(사이드바+상단 문서 탭바+중앙 DocumentHost, 메뉴 클릭=탭 열기, 공개/관람객 사이트·모바일은 MDI 비적용). ②§2.7 신설 — 2.7-1 셸 레이아웃(3영역 그리드) · 2.7-2 탭 규격(열기/활성/닫기·미들클릭·드래그 재정렬·중복방지·최대 12탭·오버플로우 스크롤+드롭다운·dirty ● 및 닫기 확인·핀 고정) · 2.7-3 탭별 상태 보존(비활성 언마운트 금지 display:none·캔버스 freeze·WebGL 메모리 가드·beforeunload) · 2.7-4 세션 복원 & 딥링크↔탭 URL 동기화(라우트 예시·localStorage 세션·행사 전환 분리) · 2.7-5 단축키(Ctrl+W/Tab/1~9/Shift+T·Ctrl+클릭 새 탭)+접근성(ARIA tablist/tab/tabpanel·로빙 탭인덱스·WCAG AA) · 2.7-6 역할별 적용(관리자·주최자·참가·장치·홀매니저=적용, 관람객/공개=비적용, 모바일 전역 비적용) · 2.7-7 컴포넌트·스토어 표준(AppShell·TabBar·Tab·DocumentHost·useMdiStore/DocumentInstance/docKey 중복방지·직렬화 규칙) + MDI 셸 Stitch 프롬프트(영어). §1 토큰 재사용, 신규 토큰 0. PLANNING.md·src/·타 문서 무수정 | diff --git a/src/backend/build.gradle b/src/backend/build.gradle index 5a0ef38..dc881b7 100644 --- a/src/backend/build.gradle +++ b/src/backend/build.gradle @@ -48,6 +48,12 @@ dependencies { runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" + // --- 2차 인증 TOTP(RFC6238) — WISE/UIWS 이식 (dev.samstevens.totp) --- + implementation 'dev.samstevens.totp:totp:1.7.1' + + // --- AOP (@Audited 감사 어드바이스, Phase B B-2/B-4) --- + implementation 'org.springframework.boot:spring-boot-starter-aop' + // --- Lombok --- compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditAspect.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditAspect.java new file mode 100644 index 0000000..8077172 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditAspect.java @@ -0,0 +1,49 @@ +package com.zioinfo.kintex.common.audit; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +/** + * {@link Audited} 부착 메서드를 감사 기록으로 감싼다(§7-3). 성공/실패를 기록하되 예외는 원형 그대로 전파한다. + * 액터는 SecurityContext 의 {@link KintexPrincipal}에서 취득. 인자 값·응답 본문은 기록하지 않는다(민감정보 차단). + */ +@Aspect +@Component +public class AuditAspect { + + private final AuditLogService auditLogService; + + public AuditAspect(AuditLogService auditLogService) { + this.auditLogService = auditLogService; + } + + @Around("@annotation(audited)") + public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable { + String actorId = null; + String actorName = null; + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof KintexPrincipal p) { + actorId = p.userId(); + actorName = p.displayName(); + } + String method = ((MethodSignature) pjp.getSignature()).getMethod().getName(); + String targetType = audited.targetType().isBlank() ? null : audited.targetType(); + try { + Object result = pjp.proceed(); + auditLogService.record(actorId, actorName, audited.action(), targetType, + null, null, "op=" + method, "SUCCESS"); + return result; + } catch (Throwable ex) { + // 실패 사유는 예외 클래스명만(스택트레이스·메시지 상세 미기록). + auditLogService.record(actorId, actorName, audited.action(), targetType, + null, null, "op=" + method + " failed=" + ex.getClass().getSimpleName(), "FAILURE"); + throw ex; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogController.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogController.java new file mode 100644 index 0000000..0189c4d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogController.java @@ -0,0 +1,34 @@ +package com.zioinfo.kintex.common.audit; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 감사 로그 조회 API (/api/admin/audit). ADMIN 게이트. */ +@RestController +@RequestMapping("/api/admin/audit") +public class AuditLogController { + + private final AuditLogService service; + private final SystemAccessGuard guard; + + public AuditLogController(AuditLogService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String action, + @RequestParam(required = false) String actorId, + @RequestParam(required = false) String eventId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.search(action, actorId, eventId, page, size)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogDto.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogDto.java new file mode 100644 index 0000000..de63a8d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.common.audit; + +/** 감사 로그 행(조회). 민감정보 미포함. */ +public record AuditLogDto( + long id, String actorId, String actorName, String action, String targetType, + String targetId, String eventId, String summary, String result, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogMapper.java new file mode 100644 index 0000000..b64f69e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogMapper.java @@ -0,0 +1,54 @@ +package com.zioinfo.kintex.common.audit; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** 감사 로그 매퍼(append-only). */ +@Mapper +public interface AuditLogMapper { + + @Insert(""" + INSERT INTO audit_log (actor_id, actor_name, action, target_type, target_id, + event_id, summary, ruleset_version, result, ip_hint) + VALUES (#{actorId}, #{actorName}, #{action}, #{targetType}, #{targetId}, + #{eventId}, #{summary}, #{rulesetVersion}, #{result}, #{ipHint}) + """) + int insert(Map p); + + @Select(""" + + """) + List> search(@Param("action") String action, @Param("actorId") String actorId, + @Param("eventId") String eventId, + @Param("size") int size, @Param("offset") int offset); + + @Select(""" + + """) + long count(@Param("action") String action, @Param("actorId") String actorId, + @Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogService.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogService.java new file mode 100644 index 0000000..ada053d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/AuditLogService.java @@ -0,0 +1,68 @@ +package com.zioinfo.kintex.common.audit; + +import com.zioinfo.kintex.common.PageResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 감사 로그 기록/조회 서비스. 기록은 실패해도 업무 트랜잭션을 막지 않는다(best-effort, 예외 삼킴). + * 민감정보·스택트레이스는 절대 기록하지 않는다(계약 §0-3/§7-3). + */ +@Service +public class AuditLogService { + + private static final Logger log = LoggerFactory.getLogger(AuditLogService.class); + private final AuditLogMapper mapper; + + public AuditLogService(AuditLogMapper mapper) { + this.mapper = mapper; + } + + /** 감사 기록(best-effort). summary 는 호출 측에서 민감정보 제거 후 전달. */ + public void record(String actorId, String actorName, String action, String targetType, + String targetId, String eventId, String summary, String result) { + try { + Map p = new HashMap<>(); + p.put("actorId", actorId); + p.put("actorName", actorName); + p.put("action", action); + p.put("targetType", nullIfBlank(targetType)); + p.put("targetId", targetId); + p.put("eventId", eventId); + p.put("summary", truncate(summary, 1000)); + p.put("rulesetVersion", null); + p.put("result", result == null ? "SUCCESS" : result); + p.put("ipHint", null); + mapper.insert(p); + } catch (Exception e) { + log.warn("감사 로그 기록 실패(action={}) — 업무 흐름 계속", action); + } + } + + public PageResponse search(String action, String actorId, String eventId, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + List> rows = mapper.search(action, actorId, eventId, s, p * s); + long total = mapper.count(action, actorId, eventId); + return PageResponse.of(rows.stream().map(AuditLogService::toDto).toList(), p, s, total); + } + + private static AuditLogDto toDto(Map r) { + return new AuditLogDto( + ((Number) r.getOrDefault("id", 0L)).longValue(), + str(r.get("actorId")), str(r.get("actorName")), str(r.get("action")), + str(r.get("targetType")), str(r.get("targetId")), str(r.get("eventId")), + str(r.get("summary")), str(r.get("result")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static String nullIfBlank(String s) { return (s == null || s.isBlank()) ? null : s; } + private static String truncate(String s, int max) { + if (s == null) return null; + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/audit/Audited.java b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/Audited.java new file mode 100644 index 0000000..595d0e4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/audit/Audited.java @@ -0,0 +1,21 @@ +package com.zioinfo.kintex.common.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 감사 대상 표시(§7-3). 상태 변경 API(승인·낙찰·설계변경·룰셋개정·사용자관리 등)에 부착하면 + * {@link AuditAspect}가 성공/실패를 {@code audit_log}에 기록한다. 민감정보·비번·스택트레이스는 기록하지 않는다. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Audited { + + /** 감사 액션 코드(예: LAYOUT_APPROVE, SYS_USER_CREATE). */ + String action(); + + /** 대상 유형(테이블/도메인, 예: app_user, layout). */ + String targetType() default ""; +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java b/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java index a7a6c54..816183e 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java @@ -14,6 +14,9 @@ public enum ErrorCode { RENDER_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "행사 이미지 생성 쿼터를 초과했습니다."), NOT_REGISTERED_COMPANY(HttpStatus.FORBIDDEN, "킨텍스 등록업체만 참여할 수 있습니다."), NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."), + ACCOUNT_LOCKED(HttpStatus.LOCKED, "로그인 시도가 일시적으로 제한되었습니다."), + OTP_REQUIRED(HttpStatus.UNAUTHORIZED, "2차 인증이 필요합니다."), + OTP_INVALID(HttpStatus.UNAUTHORIZED, "OTP 코드가 올바르지 않습니다."), INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다."); private final HttpStatus status; diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java index 8484248..1a3744e 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java @@ -33,7 +33,8 @@ public class SecurityConfig { .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/health", "/api/auth/login", "/ws/**", - "/api/internal/render/callback").permitAll() + "/api/internal/render/callback", + "/api/auth/login/secure", "/api/auth/otp/verify").permitAll() .anyRequest().authenticated()) .exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> { // 스택트레이스 미노출 — 표준 봉투로 401만 반환. diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java new file mode 100644 index 0000000..23a8d34 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/AccountSecurityMapper.java @@ -0,0 +1,62 @@ +package com.zioinfo.kintex.security; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * 계정 보안 매퍼 — 2차 인증(OTP)·로그인 실패 잠금 상태. app_user 순증 컬럼 접근. + * 보안 불변(계약 §0-3): password_hash·otp_secret 은 인증/검증 경로에서만 조회하며 응답 DTO로 노출 금지. + */ +@Mapper +public interface AccountSecurityMapper { + + /** 이메일 → 인증+보안 행(자격검증·2FA 분기용). 없으면 null. */ + @Select(""" + SELECT id AS userId, display_name AS displayName, password_hash AS passwordHash, + hall_manager AS hallManager, otp_enabled AS otpEnabled, otp_secret AS otpSecret, + role_code AS roleCode, failed_login_count AS failedCount, locked_until AS lockedUntil + FROM app_user WHERE email = #{email} AND status = 'ACTIVE' + """) + Map findAuthSecurityByEmail(@Param("email") String email); + + /** userId → 보안 요약(마이페이지 OTP 상태·발급 라벨). */ + @Select(""" + SELECT id AS userId, email, display_name AS displayName, hall_manager AS hallManager, + otp_enabled AS otpEnabled, otp_secret AS otpSecret, role_code AS roleCode + FROM app_user WHERE id = #{userId} + """) + Map findSecurityById(@Param("userId") String userId); + + /** OTP 시크릿 저장(등록 시작 — 아직 미활성). */ + @Update("UPDATE app_user SET otp_secret = #{secret}, otp_enabled = false, " + + "verify_method = 'OTP', updated_at = now() WHERE id = #{userId}") + int saveOtpSecret(@Param("userId") String userId, @Param("secret") String secret); + + /** OTP 활성(코드 확인 완료). */ + @Update("UPDATE app_user SET otp_enabled = true, updated_at = now() WHERE id = #{userId}") + int enableOtp(@Param("userId") String userId); + + /** OTP 초기화(비활성 + 시크릿 폐기) — 본인 해제/관리자 리셋. */ + @Update("UPDATE app_user SET otp_secret = NULL, otp_enabled = false, " + + "verify_method = 'EMAIL', updated_at = now() WHERE id = #{userId}") + int resetOtp(@Param("userId") String userId); + + /** 로그인 실패 카운트 증가. */ + @Update("UPDATE app_user SET failed_login_count = failed_login_count + 1, updated_at = now() " + + "WHERE email = #{email}") + int incrementFailure(@Param("email") String email); + + /** 계정 잠금(임계 초과 시 locked_until 설정). */ + @Update("UPDATE app_user SET locked_until = #{until}, updated_at = now() WHERE email = #{email}") + int lockUntil(@Param("email") String email, @Param("until") OffsetDateTime until); + + /** 로그인 성공 — 실패 카운트/잠금 해제 + 최근 로그인 기록. */ + @Update("UPDATE app_user SET failed_login_count = 0, locked_until = NULL, " + + "last_login_at = now(), updated_at = now() WHERE id = #{userId}") + int clearLoginState(@Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/AdminAccountSeeder.java b/src/backend/src/main/java/com/zioinfo/kintex/security/AdminAccountSeeder.java new file mode 100644 index 0000000..64898fa --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/AdminAccountSeeder.java @@ -0,0 +1,71 @@ +package com.zioinfo.kintex.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * 최초 관리자 프로비저닝 — 비밀번호는 env 주입만(ADMIN_PASSWORD). 하드코딩·admin123 시드 절대 금지(계약 §5B-3). + *

kintex.security.admin-seed.enabled=true 이고 email/password 가 모두 주입될 때만 멱등 upsert 한다. + * 값이 없으면 아무 것도 하지 않는다(기본 관리자 계정 없음). 비밀번호는 BCrypt 해시로만 저장하며 로그에 남기지 않는다. + */ +@Component +public class AdminAccountSeeder implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(AdminAccountSeeder.class); + + private final SecurityPolicyProperties props; + private final JdbcTemplate jdbc; + private final PasswordEncoder passwordEncoder; + + public AdminAccountSeeder(SecurityPolicyProperties props, JdbcTemplate jdbc, + PasswordEncoder passwordEncoder) { + this.props = props; + this.jdbc = jdbc; + this.passwordEncoder = passwordEncoder; + } + + @Override + public void run(ApplicationArguments args) { + SecurityPolicyProperties.AdminSeed seed = props.getAdminSeed(); + if (!seed.isEnabled()) { + return; + } + if (isBlank(seed.getEmail()) || isBlank(seed.getPassword())) { + log.warn("admin-seed enabled 이나 ADMIN_EMAIL/ADMIN_PASSWORD 미주입 → 관리자 시드 건너뜀"); + return; + } + String hash = passwordEncoder.encode(seed.getPassword()); + // 멱등 upsert: 신규면 생성, 기존이면 해시·역할·활성만 갱신(비번은 항상 env 값 기준). + jdbc.update(""" + INSERT INTO app_user (id, email, display_name, password_hash, hall_manager, + role_code, status, verify_method) + VALUES (?, ?, ?, ?, true, 'ADMIN', 'ACTIVE', 'EMAIL') + ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + role_code = 'ADMIN', + hall_manager = true, + status = 'ACTIVE', + updated_at = now() + """, + "admin-" + Integer.toHexString(seed.getEmail().toLowerCase().hashCode()), + seed.getEmail().toLowerCase(), seed.getDisplayName(), hash); + log.info("관리자 계정 프로비저닝 완료(email 마스킹): {}", mask(seed.getEmail())); + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String mask(String email) { + int at = email.indexOf('@'); + if (at <= 1) { + return "***"; + } + return email.charAt(0) + "***" + email.substring(at); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/LoginAttemptService.java b/src/backend/src/main/java/com/zioinfo/kintex/security/LoginAttemptService.java new file mode 100644 index 0000000..db1ffe5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/LoginAttemptService.java @@ -0,0 +1,64 @@ +package com.zioinfo.kintex.security; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * 로그인 실패 잠금 정책 — 연속 실패 임계 초과 시 {@code locked_until} 설정(계약 §5B-3). + * 실패 메시지는 계정 열거를 막기 위해 일반화한다. + */ +@Service +public class LoginAttemptService { + + private final AccountSecurityMapper mapper; + private final SecurityPolicyProperties props; + + public LoginAttemptService(AccountSecurityMapper mapper, SecurityPolicyProperties props) { + this.mapper = mapper; + this.props = props; + } + + /** 현재 잠금 상태면 423(LOCKED). authRow 는 findAuthSecurityByEmail 결과. */ + public void assertNotLocked(Map authRow) { + if (authRow == null) { + return; // 존재 여부 노출 방지 — 자격검증 단계에서 일반화 처리. + } + Object lu = authRow.get("lockedUntil"); + if (lu instanceof OffsetDateTime until && until.isAfter(OffsetDateTime.now())) { + throw new ApiException(ErrorCode.ACCOUNT_LOCKED, + "로그인 시도가 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요."); + } + } + + /** 자격 실패 기록 — 임계 초과 시 잠금. */ + public void recordFailure(String email, Map authRow) { + if (authRow == null) { + return; // 미존재 계정은 상태 변경 없음. + } + mapper.incrementFailure(email); + int failed = toInt(authRow.get("failedCount")) + 1; + if (failed >= props.getLockout().getMaxFailedAttempts()) { + mapper.lockUntil(email, OffsetDateTime.now().plusMinutes(props.getLockout().getLockMinutes())); + } + } + + /** 로그인 성공 — 실패 카운트/잠금 해제. */ + public void recordSuccess(String userId) { + mapper.clearLoginState(userId); + } + + private static int toInt(Object o) { + if (o instanceof Number n) { + return n.intValue(); + } + try { + return o == null ? 0 : Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java b/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java new file mode 100644 index 0000000..70d9a13 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/OtpChallengeStore.java @@ -0,0 +1,50 @@ +package com.zioinfo.kintex.security; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OTP 2단계 인증 챌린지 저장소(무상태 JWT 아키텍처의 짧은 수명 프리오스 토큰). + * 메모리 기반·5분 TTL·1회성. 토큰→userId. (단일 노드 기준; 다중 노드 확장 시 Redis 대체 가능.) + */ +@Component +public class OtpChallengeStore { + + private static final long TTL_MILLIS = 5 * 60 * 1000L; + private final SecureRandom random = new SecureRandom(); + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + private record Entry(String userId, long expiresAt) { + } + + /** userId에 대한 1회성 챌린지 토큰 발급. */ + public String issue(String userId) { + purgeExpired(); + byte[] buf = new byte[24]; + random.nextBytes(buf); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + store.put(token, new Entry(userId, Instant.now().toEpochMilli() + TTL_MILLIS)); + return token; + } + + /** 토큰 소비(1회성) → userId. 만료/무효면 null. */ + public String consume(String token) { + if (token == null) { + return null; + } + Entry e = store.remove(token); + if (e == null || e.expiresAt() < Instant.now().toEpochMilli()) { + return null; + } + return e.userId(); + } + + private void purgeExpired() { + long now = Instant.now().toEpochMilli(); + store.entrySet().removeIf(en -> en.getValue().expiresAt() < now); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java b/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java new file mode 100644 index 0000000..d849429 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/SecurityPolicyProperties.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * kintex.security.* 바인딩 — 2차 인증(OTP)·로그인 실패 잠금·admin 시드 정책. + * 시크릿·비밀번호는 env 주입만(하드코딩 금지, 계약 §5B-3). admin 시드는 값이 모두 주입될 때만 동작. + */ +@ConfigurationProperties(prefix = "kintex.security") +public class SecurityPolicyProperties { + + private final Lockout lockout = new Lockout(); + private final Otp otp = new Otp(); + private final AdminSeed adminSeed = new AdminSeed(); + + public Lockout getLockout() { return lockout; } + public Otp getOtp() { return otp; } + public AdminSeed getAdminSeed() { return adminSeed; } + + public static class Lockout { + /** 잠금 임계치(연속 실패 횟수). */ + private int maxFailedAttempts = 5; + /** 잠금 유지 시간(분). */ + private int lockMinutes = 15; + public int getMaxFailedAttempts() { return maxFailedAttempts; } + public void setMaxFailedAttempts(int v) { this.maxFailedAttempts = v; } + public int getLockMinutes() { return lockMinutes; } + public void setLockMinutes(int v) { this.lockMinutes = v; } + } + + public static class Otp { + /** TOTP 발급자 표기(Authenticator 앱 라벨). */ + private String issuer = "KINTEX"; + public String getIssuer() { return issuer; } + public void setIssuer(String v) { this.issuer = v; } + } + + public static class AdminSeed { + private boolean enabled = false; + private String email; + private String password; + private String displayName = "시스템관리자"; + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean v) { this.enabled = v; } + public String getEmail() { return email; } + public void setEmail(String v) { this.email = v; } + public String getPassword() { return password; } + public void setPassword(String v) { this.password = v; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String v) { this.displayName = v; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/TotpService.java b/src/backend/src/main/java/com/zioinfo/kintex/security/TotpService.java new file mode 100644 index 0000000..abd4dc3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/TotpService.java @@ -0,0 +1,83 @@ +package com.zioinfo.kintex.security; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrGenerator; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import dev.samstevens.totp.util.Utils; +import org.springframework.stereotype.Service; + +/** + * TOTP(RFC 6238, HMAC-SHA1, 30s, 6자리) 2차 인증 — WISE/UIWS TotpService 이식. + * 시크릿(app_user.otp_secret)은 응답/로그 노출 금지(계약 §0-3). ±1 윈도우 허용(시계 오차). + */ +@Service +public class TotpService { + + private final TimeProvider timeProvider = new SystemTimeProvider(); + private final CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6); + private final SecretGenerator secretGenerator = new DefaultSecretGenerator(); + private final CodeVerifier codeVerifier = buildVerifier(); + private final QrGenerator qrGenerator = new ZxingPngQrGenerator(); + + private final String issuer; + + public TotpService(SecurityPolicyProperties props) { + this.issuer = props.getOtp().getIssuer(); + } + + private CodeVerifier buildVerifier() { + DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + verifier.setTimePeriod(30); + verifier.setAllowedTimePeriodDiscrepancy(1); + return verifier; + } + + /** 신규 OTP 시크릿 생성(등록용). */ + public String generateSecret() { + return secretGenerator.generate(); + } + + /** 시크릿에 대해 사용자 입력 코드 검증. */ + public boolean verify(String secret, String code) { + if (secret == null || secret.isBlank() || code == null || code.isBlank()) { + return false; + } + return codeVerifier.isValidCode(secret, code.trim()); + } + + /** Authenticator 앱 직접 등록용 otpauth:// URI. */ + public String otpAuthUri(String secret, String userLabel) { + return buildQrData(secret, userLabel).getUri(); + } + + /** QR 이미지(data:image/png;base64,...). */ + public String qrImageDataUri(String secret, String userLabel) { + try { + byte[] image = qrGenerator.generate(buildQrData(secret, userLabel)); + return Utils.getDataUriForImage(image, qrGenerator.getImageMimeType()); + } catch (QrGenerationException e) { + throw new IllegalStateException("OTP QR 생성 실패", e); + } + } + + private QrData buildQrData(String secret, String userLabel) { + return new QrData.Builder() + .label(userLabel) + .secret(secret) + .issuer(issuer) + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java new file mode 100644 index 0000000..7a67fb4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorController.java @@ -0,0 +1,80 @@ +package com.zioinfo.kintex.security; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.auth.dto.LoginResponse; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.security.dto.OtpConfirmRequest; +import com.zioinfo.kintex.security.dto.OtpSetupResponse; +import com.zioinfo.kintex.security.dto.OtpStatusResponse; +import com.zioinfo.kintex.security.dto.OtpVerifyRequest; +import com.zioinfo.kintex.security.dto.SecureLoginRequest; +import com.zioinfo.kintex.security.dto.SecureLoginResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 2차 인증(OTP)·잠금 적용 로그인 API (§5B). 기존 /api/auth/login 은 유지하고 본 레이어를 추가 제공한다. + *

    + *
  • POST /api/auth/login/secure — 1단계(잠금+자격검증). OTP 사용자면 challengeToken 반환.
  • + *
  • POST /api/auth/otp/verify — 2단계(OTP 코드) → 최종 토큰.
  • + *
  • setup/confirm/status/reset — 로그인 사용자의 OTP 등록/해제(본인).
  • + *
+ */ +@RestController +@RequestMapping("/api/auth") +public class TwoFactorController { + + private final TwoFactorService service; + private final EventAccessGuard guard; + + public TwoFactorController(TwoFactorService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** 공개 — 1단계 로그인(잠금+자격검증+OTP 분기). */ + @PostMapping("/login/secure") + public ApiResponse secureLogin(@Valid @RequestBody SecureLoginRequest req) { + return ApiResponse.ok(service.secureLogin(req.email(), req.password())); + } + + /** 공개 — 2단계 OTP 검증 → 최종 토큰. */ + @PostMapping("/otp/verify") + public ApiResponse verifyOtp(@Valid @RequestBody OtpVerifyRequest req) { + return ApiResponse.ok(service.verifyOtp(req.challengeToken(), req.code())); + } + + /** 인증 — OTP 등록 시작(시크릿·QR 발급). */ + @PostMapping("/otp/setup") + public ApiResponse setup(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + String label = principal.displayName() != null ? principal.displayName() : principal.userId(); + return ApiResponse.ok(service.setupOtp(principal.userId(), label)); + } + + /** 인증 — OTP 등록 확인(활성화). */ + @PostMapping("/otp/confirm") + public ApiResponse confirm(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody OtpConfirmRequest req) { + guard.require(principal); + service.confirmOtp(principal.userId(), req.code()); + return ApiResponse.ok(null); + } + + /** 인증 — 본인 OTP 상태. */ + @GetMapping("/otp/status") + public ApiResponse status(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + return ApiResponse.ok(service.otpStatus(principal.userId())); + } + + /** 인증 — 본인 OTP 해제. */ + @PostMapping("/otp/reset") + public ApiResponse resetSelf(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + service.resetOtp(principal.userId()); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java new file mode 100644 index 0000000..c2daa50 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/TwoFactorService.java @@ -0,0 +1,153 @@ +package com.zioinfo.kintex.security; + +import com.zioinfo.kintex.auth.EventRole; +import com.zioinfo.kintex.auth.JwtService; +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 com.zioinfo.kintex.security.dto.OtpSetupResponse; +import com.zioinfo.kintex.security.dto.OtpStatusResponse; +import com.zioinfo.kintex.security.dto.SecureLoginResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 2차 인증(OTP)·로그인 실패 잠금 오케스트레이션 레이어(§5B). + *

기존 {@code AuthServiceImpl}/{@code JwtService}는 교체하지 않고 이 레이어만 얹는다 — + * 자격검증·토큰발급은 기존 빈(UserMapper·PasswordEncoder·JwtService)을 그대로 재사용한다. + * 보안 불변(계약 §0-3): password_hash·otp_secret 은 응답에 절대 노출하지 않는다. + */ +@Service +public class TwoFactorService { + + private final AccountSecurityMapper securityMapper; + private final UserMapper userMapper; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final TotpService totpService; + private final LoginAttemptService loginAttempts; + private final OtpChallengeStore challengeStore; + + public TwoFactorService(AccountSecurityMapper securityMapper, UserMapper userMapper, + JwtService jwtService, PasswordEncoder passwordEncoder, + TotpService totpService, LoginAttemptService loginAttempts, + OtpChallengeStore challengeStore) { + this.securityMapper = securityMapper; + this.userMapper = userMapper; + this.jwtService = jwtService; + this.passwordEncoder = passwordEncoder; + this.totpService = totpService; + this.loginAttempts = loginAttempts; + this.challengeStore = challengeStore; + } + + /** 1단계: 잠금 확인 → 자격검증 → (OTP면 챌린지, 아니면 토큰). */ + public SecureLoginResponse secureLogin(String email, String rawPassword) { + Map row = securityMapper.findAuthSecurityByEmail(email); + loginAttempts.assertNotLocked(row); + String hash = row == null ? null : str(row.get("passwordHash")); + if (row == null || hash == null || !passwordEncoder.matches(rawPassword, hash)) { + loginAttempts.recordFailure(email, row); + throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); + } + String userId = str(row.get("userId")); + loginAttempts.recordSuccess(userId); + if (Boolean.TRUE.equals(bool(row.get("otpEnabled")))) { + return SecureLoginResponse.otpRequired(challengeStore.issue(userId)); + } + return SecureLoginResponse.ok(buildLoginResponse(userId, + str(row.get("displayName")), bool(row.get("hallManager")))); + } + + /** 2단계: 챌린지 토큰 + OTP 코드 검증 → 최종 로그인 토큰. */ + public LoginResponse verifyOtp(String challengeToken, String code) { + String userId = challengeStore.consume(challengeToken); + if (userId == null) { + throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); + } + Map sec = securityMapper.findSecurityById(userId); + if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) { + throw new ApiException(ErrorCode.OTP_INVALID); + } + return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager"))); + } + + /** OTP 등록 시작 — 시크릿 발급·QR 반환(미활성, confirm 필요). */ + public OtpSetupResponse setupOtp(String userId, String userLabel) { + String secret = totpService.generateSecret(); + securityMapper.saveOtpSecret(userId, secret); + return new OtpSetupResponse(secret, + totpService.otpAuthUri(secret, userLabel), + totpService.qrImageDataUri(secret, userLabel)); + } + + /** OTP 등록 확인 — 코드 검증 후 활성화. */ + public void confirmOtp(String userId, String code) { + Map sec = securityMapper.findSecurityById(userId); + if (sec == null || !totpService.verify(str(sec.get("otpSecret")), code)) { + throw new ApiException(ErrorCode.OTP_INVALID); + } + securityMapper.enableOtp(userId); + } + + /** OTP 초기화(본인 해제 또는 관리자 리셋). */ + public void resetOtp(String userId) { + securityMapper.resetOtp(userId); + } + + /** OTP 상태(마이페이지). 시크릿 미포함. */ + public OtpStatusResponse otpStatus(String userId) { + Map sec = securityMapper.findSecurityById(userId); + boolean enabled = sec != null && Boolean.TRUE.equals(bool(sec.get("otpEnabled"))); + return new OtpStatusResponse(enabled, enabled ? "OTP" : "EMAIL"); + } + + /** 사용자 역할 → 토큰 + 워크스페이스 카드 조립(AuthServiceImpl 로직 정합, 토큰만 재발급). */ + private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) { + List> roleRows = userMapper.findEventRoles(userId); + Map eventRoles = new LinkedHashMap<>(); + if (roleRows != null) { + for (Map r : roleRows) { + String eventId = str(r.get("eventId")); + EventRole role = parseRole(str(r.get("roleCode"))); + if (eventId != null && role != null) { + eventRoles.put(eventId, role); + } + } + } + String token = jwtService.issue(userId, displayName, eventRoles, hallManager); + List workspaces = new ArrayList<>(); + if (roleRows != null) { + for (Map r : roleRows) { + workspaces.add(new WorkspaceDto(str(r.get("eventId")), str(r.get("eventName")), + str(r.get("startDate")), str(r.get("endDate")), str(r.get("hallLabel")), + str(r.get("roleCode")), intVal(r.get("dday")))); + } + } + return new LoginResponse(token, jwtService.ttlSeconds(), + new LoginResponse.UserSummary(userId, displayName, hallManager), workspaces); + } + + private static EventRole parseRole(String code) { + if (code == null) return null; + try { return EventRole.valueOf(code); } catch (IllegalArgumentException e) { return null; } + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static Boolean bool(Object o) { + if (o == null) return null; + if (o instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(o)); + } + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpConfirmRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpConfirmRequest.java new file mode 100644 index 0000000..2af9fc1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpConfirmRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.security.dto; + +import jakarta.validation.constraints.NotBlank; + +/** OTP 등록 확인 — Authenticator 앱 코드로 활성화. */ +public record OtpConfirmRequest(@NotBlank String code) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpSetupResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpSetupResponse.java new file mode 100644 index 0000000..8d91137 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpSetupResponse.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.security.dto; + +/** OTP 등록 시작 응답 — 시크릿·otpauth URI·QR(data URI). 시크릿은 등록 화면 1회 표기용. */ +public record OtpSetupResponse(String secret, String otpAuthUri, String qrImageDataUri) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpStatusResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpStatusResponse.java new file mode 100644 index 0000000..f2c72d8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpStatusResponse.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.security.dto; + +/** 마이페이지 OTP 상태(활성 여부·검증 방식). 시크릿 미포함. */ +public record OtpStatusResponse(boolean otpEnabled, String verifyMethod) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpVerifyRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpVerifyRequest.java new file mode 100644 index 0000000..6915cf9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/OtpVerifyRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.security.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 로그인 2단계 — 발급된 challengeToken + OTP 코드. */ +public record OtpVerifyRequest(@NotBlank String challengeToken, @NotBlank String code) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginRequest.java new file mode 100644 index 0000000..9004bab --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginRequest.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.security.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** 2FA/잠금 적용 로그인 1단계 요청. */ +public record SecureLoginRequest(@NotBlank @Email String email, @NotBlank String password) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java new file mode 100644 index 0000000..0ce716b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/security/dto/SecureLoginResponse.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.security.dto; + +import com.zioinfo.kintex.auth.dto.LoginResponse; + +/** + * 2FA 로그인 1단계 응답. status: + * - "OK" → OTP 미사용. {@code login}에 최종 토큰/워크스페이스. + * - "OTP_REQUIRED" → 2차 인증 필요. {@code challengeToken}으로 /otp/verify 진행. + */ +public record SecureLoginResponse(String status, String challengeToken, LoginResponse login) { + + public static SecureLoginResponse ok(LoginResponse login) { + return new SecureLoginResponse("OK", null, login); + } + + public static SecureLoginResponse otpRequired(String challengeToken) { + return new SecureLoginResponse("OTP_REQUIRED", challengeToken, null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/SystemAccessGuard.java b/src/backend/src/main/java/com/zioinfo/kintex/system/SystemAccessGuard.java new file mode 100644 index 0000000..3f8cc97 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/SystemAccessGuard.java @@ -0,0 +1,68 @@ +package com.zioinfo.kintex.system; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.sysuser.SysUserMapper; +import org.springframework.stereotype.Component; + +/** + * 시스템관리(백오피스) 접근 가드 — 전역 ADMIN 축(이중 RBAC 중 플랫폼 역할). + *

홀매니저(킨텍스 내부 운영/관리자)는 ADMIN으로 간주하고, 그 외에는 {@code app_user.role_code=ADMIN}을 조회한다. + * 행사 스코프 권한은 {@code EventAccessGuard}가 담당(직교). /api/admin/** 은 본 가드로 게이트한다. + */ +@Component +public class SystemAccessGuard { + + private final SysUserMapper sysUserMapper; + + public SystemAccessGuard(SysUserMapper sysUserMapper) { + this.sysUserMapper = sysUserMapper; + } + + /** 인증 필수. */ + public KintexPrincipal require(KintexPrincipal principal) { + if (principal == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED); + } + return principal; + } + + /** 시스템관리자 필수(홀매니저 또는 전역 ADMIN). 아니면 403. */ + public KintexPrincipal requireAdmin(KintexPrincipal principal) { + require(principal); + if (principal.hallManager()) { + return principal; + } + String role = sysUserMapper.findRoleCode(principal.userId()); + if (!"ADMIN".equals(role)) { + throw new ApiException(ErrorCode.FORBIDDEN, "시스템관리 권한이 필요합니다."); + } + return principal; + } + + /** 데이터 전체열람 가능 여부(홀매니저·MANAGER·ADMIN). 업무모듈 DataScope 판정용(비throw). */ + public boolean canSeeAll(KintexPrincipal principal) { + if (principal == null) { + return false; + } + if (principal.hallManager()) { + return true; + } + String role = sysUserMapper.findRoleCode(principal.userId()); + return "ADMIN".equals(role) || "MANAGER".equals(role); + } + + /** 매니저 이상(MANAGER/ADMIN 또는 홀매니저) — 팀 범위 관리 액션용. */ + public KintexPrincipal requireManager(KintexPrincipal principal) { + require(principal); + if (principal.hallManager()) { + return principal; + } + String role = sysUserMapper.findRoleCode(principal.userId()); + if (!"ADMIN".equals(role) && !"MANAGER".equals(role)) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return principal; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeDto.java new file mode 100644 index 0000000..4351642 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.system.commoncode; + +/** 공통코드 값(TB_CODE 이식). code=영문 상수, codeName=한글 표기. */ +public record CodeDto(String grpCode, String code, String codeName, String codeValue, + int sortOrder, String useYn, String attr1) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeGroupDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeGroupDto.java new file mode 100644 index 0000000..0ed3946 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CodeGroupDto.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.system.commoncode; + +/** 공통코드 그룹(TB_CODE_GRP 이식). */ +public record CodeGroupDto(String grpCode, String grpName, String description, String useYn, int sortOrder) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeController.java new file mode 100644 index 0000000..539bce8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeController.java @@ -0,0 +1,81 @@ +package com.zioinfo.kintex.system.commoncode; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 공통코드 API. 조회(드롭다운)는 인증 사용자 공개, 관리(CRUD)는 ADMIN. + * - GET /api/common/code-groups, /api/common/codes/{grpCode} (인증) + * - /api/admin/codes/** (ADMIN) + */ +@RestController +public class CommonCodeController { + + private final CommonCodeService service; + private final EventAccessGuard authGuard; + private final SystemAccessGuard adminGuard; + + public CommonCodeController(CommonCodeService service, EventAccessGuard authGuard, + SystemAccessGuard adminGuard) { + this.service = service; + this.authGuard = authGuard; + this.adminGuard = adminGuard; + } + + @GetMapping("/api/common/code-groups") + public ApiResponse> groups(@AuthenticationPrincipal KintexPrincipal principal) { + authGuard.require(principal); + return ApiResponse.ok(service.groups()); + } + + @GetMapping("/api/common/codes/{grpCode}") + public ApiResponse> codes(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String grpCode) { + authGuard.require(principal); + return ApiResponse.ok(service.codes(grpCode)); + } + + @Audited(action = "CODE_GROUP_SAVE", targetType = "common_code_group") + @PostMapping("/api/admin/codes/groups") + public ApiResponse saveGroup(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody CodeGroupDto group) { + adminGuard.requireAdmin(principal); + service.saveGroup(group); + return ApiResponse.ok(null); + } + + @Audited(action = "CODE_GROUP_DELETE", targetType = "common_code_group") + @DeleteMapping("/api/admin/codes/groups/{grpCode}") + public ApiResponse deleteGroup(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String grpCode) { + adminGuard.requireAdmin(principal); + service.deleteGroup(grpCode); + return ApiResponse.ok(null); + } + + @Audited(action = "CODE_SAVE", targetType = "common_code") + @PostMapping("/api/admin/codes") + public ApiResponse saveCode(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody CodeDto code) { + adminGuard.requireAdmin(principal); + service.saveCode(code); + return ApiResponse.ok(null); + } + + @Audited(action = "CODE_DELETE", targetType = "common_code") + @DeleteMapping("/api/admin/codes/{grpCode}/{code}") + public ApiResponse deleteCode(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String grpCode, @PathVariable String code) { + adminGuard.requireAdmin(principal); + service.deleteCode(grpCode, code); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeMapper.java new file mode 100644 index 0000000..9c9709a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeMapper.java @@ -0,0 +1,41 @@ +package com.zioinfo.kintex.system.commoncode; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 공통코드 그룹/값 매퍼(TB_CODE_GRP/TB_CODE 이식). */ +@Mapper +public interface CommonCodeMapper { + + @Select("SELECT grp_code AS grpCode, grp_name AS grpName, description, use_yn AS useYn, " + + "sort_order AS sortOrder FROM common_code_group ORDER BY sort_order, grp_code") + List> findGroups(); + + @Select("SELECT grp_code AS grpCode, code, code_name AS codeName, code_value AS codeValue, " + + "sort_order AS sortOrder, use_yn AS useYn, attr1 FROM common_code " + + "WHERE grp_code = #{grpCode} AND use_yn = 'Y' ORDER BY sort_order, code") + List> findCodes(@Param("grpCode") String grpCode); + + @Insert("INSERT INTO common_code_group (grp_code, grp_name, description, use_yn, sort_order) " + + "VALUES (#{grpCode}, #{grpName}, #{description}, COALESCE(#{useYn},'Y'), COALESCE(#{sortOrder},0)) " + + "ON CONFLICT (grp_code) DO UPDATE SET grp_name=EXCLUDED.grp_name, " + + "description=EXCLUDED.description, use_yn=EXCLUDED.use_yn, sort_order=EXCLUDED.sort_order, " + + "updated_at=now()") + int upsertGroup(Map p); + + @Delete("DELETE FROM common_code_group WHERE grp_code = #{grpCode}") + int deleteGroup(@Param("grpCode") String grpCode); + + @Insert("INSERT INTO common_code (grp_code, code, code_name, code_value, sort_order, use_yn, attr1) " + + "VALUES (#{grpCode}, #{code}, #{codeName}, #{codeValue}, COALESCE(#{sortOrder},0), " + + "COALESCE(#{useYn},'Y'), #{attr1}) " + + "ON CONFLICT (grp_code, code) DO UPDATE SET code_name=EXCLUDED.code_name, " + + "code_value=EXCLUDED.code_value, sort_order=EXCLUDED.sort_order, use_yn=EXCLUDED.use_yn, " + + "attr1=EXCLUDED.attr1, updated_at=now()") + int upsertCode(Map p); + + @Delete("DELETE FROM common_code WHERE grp_code = #{grpCode} AND code = #{code}") + int deleteCode(@Param("grpCode") String grpCode, @Param("code") String code); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeService.java new file mode 100644 index 0000000..810b6d6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/commoncode/CommonCodeService.java @@ -0,0 +1,68 @@ +package com.zioinfo.kintex.system.commoncode; + +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 공통코드 조회/관리. 도메인 모듈은 하드코딩 대신 본 서비스를 통해 코드 조회(§7-4). */ +@Service +public class CommonCodeService { + + private final CommonCodeMapper mapper; + + public CommonCodeService(CommonCodeMapper mapper) { + this.mapper = mapper; + } + + public List groups() { + return mapper.findGroups().stream().map(r -> new CodeGroupDto( + str(r.get("grpCode")), str(r.get("grpName")), str(r.get("description")), + str(r.get("useYn")), intVal(r.get("sortOrder")))).toList(); + } + + public List codes(String grpCode) { + return mapper.findCodes(grpCode).stream().map(r -> new CodeDto( + str(r.get("grpCode")), str(r.get("code")), str(r.get("codeName")), + str(r.get("codeValue")), intVal(r.get("sortOrder")), str(r.get("useYn")), + str(r.get("attr1")))).toList(); + } + + public void saveGroup(CodeGroupDto g) { + Map p = new HashMap<>(); + p.put("grpCode", g.grpCode()); + p.put("grpName", g.grpName()); + p.put("description", g.description()); + p.put("useYn", g.useYn()); + p.put("sortOrder", g.sortOrder()); + mapper.upsertGroup(p); + } + + public void deleteGroup(String grpCode) { + mapper.deleteGroup(grpCode); + } + + public void saveCode(CodeDto c) { + Map p = new HashMap<>(); + p.put("grpCode", c.grpCode()); + p.put("code", c.code()); + p.put("codeName", c.codeName()); + p.put("codeValue", c.codeValue()); + p.put("sortOrder", c.sortOrder()); + p.put("useYn", c.useYn()); + p.put("attr1", c.attr1()); + mapper.upsertCode(p); + } + + public void deleteCode(String grpCode, String code) { + mapper.deleteCode(grpCode, code); + } + + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuController.java new file mode 100644 index 0000000..da92e69 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuController.java @@ -0,0 +1,62 @@ +package com.zioinfo.kintex.system.menu; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import com.zioinfo.kintex.system.sysuser.SysUserMapper; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 메뉴 API. GET /api/common/menus(인증, 역할 트리) · /api/admin/menus/**(ADMIN CRUD). */ +@RestController +public class MenuController { + + private final MenuService service; + private final EventAccessGuard authGuard; + private final SystemAccessGuard adminGuard; + private final SysUserMapper sysUserMapper; + + public MenuController(MenuService service, EventAccessGuard authGuard, + SystemAccessGuard adminGuard, SysUserMapper sysUserMapper) { + this.service = service; + this.authGuard = authGuard; + this.adminGuard = adminGuard; + this.sysUserMapper = sysUserMapper; + } + + @GetMapping("/api/common/menus") + public ApiResponse> menus(@AuthenticationPrincipal KintexPrincipal principal) { + authGuard.require(principal); + String role = sysUserMapper.findRoleCode(principal.userId()); + return ApiResponse.ok(service.tree(role, principal.hallManager())); + } + + @GetMapping("/api/admin/menus") + public ApiResponse> all(@AuthenticationPrincipal KintexPrincipal principal) { + adminGuard.requireAdmin(principal); + return ApiResponse.ok(service.flat()); + } + + @Audited(action = "MENU_SAVE", targetType = "sys_menu") + @PostMapping("/api/admin/menus") + public ApiResponse save(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody MenuDto menu) { + adminGuard.requireAdmin(principal); + service.save(menu); + return ApiResponse.ok(null); + } + + @Audited(action = "MENU_DELETE", targetType = "sys_menu") + @DeleteMapping("/api/admin/menus/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + adminGuard.requireAdmin(principal); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuDto.java new file mode 100644 index 0000000..4c48413 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuDto.java @@ -0,0 +1,9 @@ +package com.zioinfo.kintex.system.menu; + +import java.util.List; + +/** 메뉴 노드(TB_MENU 이식) — 트리 응답. children 는 하위 메뉴. */ +public record MenuDto( + String id, String parentId, String menuName, String menuPath, String icon, + String prgType, String requiredRole, int sortOrder, String useYn, List children) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuMapper.java new file mode 100644 index 0000000..086cd6b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuMapper.java @@ -0,0 +1,27 @@ +package com.zioinfo.kintex.system.menu; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 메뉴 매퍼(TB_MENU 이식). */ +@Mapper +public interface MenuMapper { + + @Select("SELECT id, parent_id AS parentId, menu_name AS menuName, menu_path AS menuPath, icon, " + + "prg_type AS prgType, required_role AS requiredRole, sort_order AS sortOrder, use_yn AS useYn " + + "FROM sys_menu ORDER BY sort_order, id") + List> findAll(); + + @Insert("INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, icon, prg_type, required_role, " + + "sort_order, use_yn) VALUES (#{id}, #{parentId}, #{menuName}, #{menuPath}, #{icon}, " + + "COALESCE(#{prgType},'FORM'), #{requiredRole}, COALESCE(#{sortOrder},0), COALESCE(#{useYn},'Y')) " + + "ON CONFLICT (id) DO UPDATE SET parent_id=EXCLUDED.parent_id, menu_name=EXCLUDED.menu_name, " + + "menu_path=EXCLUDED.menu_path, icon=EXCLUDED.icon, prg_type=EXCLUDED.prg_type, " + + "required_role=EXCLUDED.required_role, sort_order=EXCLUDED.sort_order, use_yn=EXCLUDED.use_yn") + int upsert(Map p); + + @Delete("DELETE FROM sys_menu WHERE id = #{id}") + int delete(@Param("id") String id); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuService.java new file mode 100644 index 0000000..0d230bb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/menu/MenuService.java @@ -0,0 +1,95 @@ +package com.zioinfo.kintex.system.menu; + +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 메뉴 트리 구성·관리. 역할별 노출(required_role) 필터를 적용해 사이드바 트리를 제공한다. */ +@Service +public class MenuService { + + private final MenuMapper mapper; + + public MenuService(MenuMapper mapper) { + this.mapper = mapper; + } + + /** 사용자 전역역할(rank)로 필터링된 메뉴 트리. isAdmin 이면 전체. */ + public List tree(String userRole, boolean isAdmin) { + List> rows = mapper.findAll(); + int rank = rank(isAdmin ? "ADMIN" : userRole); + // 1) 노드 생성(가시성 필터: use_yn=Y 이고 required_role 이하) + Map byId = new HashMap<>(); + List> visible = new ArrayList<>(); + for (Map r : rows) { + if (!"Y".equals(str(r.get("useYn")))) continue; + String req = str(r.get("requiredRole")); + if (req != null && rank < rank(req)) continue; + visible.add(r); + } + for (Map r : visible) { + byId.put(str(r.get("id")), toNode(r, new ArrayList<>())); + } + // 2) 부모-자식 연결 + List roots = new ArrayList<>(); + for (Map r : visible) { + String id = str(r.get("id")); + String parentId = str(r.get("parentId")); + MenuDto node = byId.get(id); + if (parentId != null && byId.containsKey(parentId)) { + byId.get(parentId).children().add(node); + } else { + roots.add(node); + } + } + return roots; + } + + public List flat() { + return mapper.findAll().stream().map(r -> toNode(r, List.of())).toList(); + } + + public void save(MenuDto m) { + Map p = new HashMap<>(); + p.put("id", m.id()); + p.put("parentId", m.parentId()); + p.put("menuName", m.menuName()); + p.put("menuPath", m.menuPath()); + p.put("icon", m.icon()); + p.put("prgType", m.prgType()); + p.put("requiredRole", m.requiredRole()); + p.put("sortOrder", m.sortOrder()); + p.put("useYn", m.useYn()); + mapper.upsert(p); + } + + public void delete(String id) { + mapper.delete(id); + } + + private static MenuDto toNode(Map r, List children) { + return new MenuDto(str(r.get("id")), str(r.get("parentId")), str(r.get("menuName")), + str(r.get("menuPath")), str(r.get("icon")), str(r.get("prgType")), + str(r.get("requiredRole")), intVal(r.get("sortOrder")), str(r.get("useYn")), children); + } + + /** 역할 서열: USER 3; + case "MANAGER" -> 2; + case "USER" -> 1; + default -> 0; + }; + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/role/PermissionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/role/PermissionDto.java new file mode 100644 index 0000000..0937684 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/role/PermissionDto.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.system.role; + +/** 권한 정의. */ +public record PermissionDto(String permCode, String permName, String description) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleController.java new file mode 100644 index 0000000..46a2c87 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleController.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.system.role; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 역할/권한 관리 API (/api/admin/roles). ADMIN 게이트. */ +@RestController +@RequestMapping("/api/admin/roles") +public class RoleController { + + private final RoleService service; + private final SystemAccessGuard guard; + + public RoleController(RoleService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> roles(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.roles()); + } + + @GetMapping("/permissions") + public ApiResponse> permissions(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.permissions()); + } + + @Audited(action = "ROLE_SAVE", targetType = "sys_role") + @PostMapping + public ApiResponse save(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody RoleDto role) { + guard.requireAdmin(principal); + service.save(role); + return ApiResponse.ok(null); + } + + @Audited(action = "ROLE_DELETE", targetType = "sys_role") + @DeleteMapping("/{roleCode}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String roleCode) { + guard.requireAdmin(principal); + service.delete(roleCode); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleDto.java new file mode 100644 index 0000000..d483784 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.system.role; + +import java.util.List; + +/** 역할(RBAC) + 부여 권한 코드 목록. */ +public record RoleDto(String roleCode, String roleName, String description, String useYn, List permissions) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleMapper.java new file mode 100644 index 0000000..4dd0c51 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleMapper.java @@ -0,0 +1,38 @@ +package com.zioinfo.kintex.system.role; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 역할/권한 매퍼(RBAC 확장). */ +@Mapper +public interface RoleMapper { + + @Select("SELECT role_code AS roleCode, role_name AS roleName, description, use_yn AS useYn " + + "FROM sys_role ORDER BY role_code") + List> findRoles(); + + @Select("SELECT perm_code AS permCode, perm_name AS permName, description " + + "FROM sys_permission ORDER BY perm_code") + List> findPermissions(); + + @Select("SELECT perm_code FROM sys_role_permission WHERE role_code = #{roleCode} ORDER BY perm_code") + List findRolePermissions(@Param("roleCode") String roleCode); + + @Insert("INSERT INTO sys_role (role_code, role_name, description, use_yn) " + + "VALUES (#{roleCode}, #{roleName}, #{description}, COALESCE(#{useYn},'Y')) " + + "ON CONFLICT (role_code) DO UPDATE SET role_name=EXCLUDED.role_name, " + + "description=EXCLUDED.description, use_yn=EXCLUDED.use_yn") + int upsertRole(Map p); + + @Delete("DELETE FROM sys_role WHERE role_code = #{roleCode}") + int deleteRole(@Param("roleCode") String roleCode); + + @Delete("DELETE FROM sys_role_permission WHERE role_code = #{roleCode}") + int clearRolePermissions(@Param("roleCode") String roleCode); + + @Insert("INSERT INTO sys_role_permission (role_code, perm_code) VALUES (#{roleCode}, #{permCode}) " + + "ON CONFLICT DO NOTHING") + int addRolePermission(@Param("roleCode") String roleCode, @Param("permCode") String permCode); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleService.java new file mode 100644 index 0000000..f38601f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/role/RoleService.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.system.role; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 역할/권한(RBAC) 관리. 역할-권한 매핑을 통째로 교체(clear+insert)로 갱신한다. */ +@Service +public class RoleService { + + private final RoleMapper mapper; + + public RoleService(RoleMapper mapper) { + this.mapper = mapper; + } + + public List roles() { + return mapper.findRoles().stream().map(r -> { + String code = str(r.get("roleCode")); + return new RoleDto(code, str(r.get("roleName")), str(r.get("description")), + str(r.get("useYn")), mapper.findRolePermissions(code)); + }).toList(); + } + + public List permissions() { + return mapper.findPermissions().stream().map(r -> new PermissionDto( + str(r.get("permCode")), str(r.get("permName")), str(r.get("description")))).toList(); + } + + @Transactional + public void save(RoleDto role) { + Map p = new HashMap<>(); + p.put("roleCode", role.roleCode()); + p.put("roleName", role.roleName()); + p.put("description", role.description()); + p.put("useYn", role.useYn()); + mapper.upsertRole(p); + mapper.clearRolePermissions(role.roleCode()); + if (role.permissions() != null) { + for (String perm : role.permissions()) { + mapper.addRolePermission(role.roleCode(), perm); + } + } + } + + @Transactional + public void delete(String roleCode) { + mapper.deleteRole(roleCode); + } + + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingController.java new file mode 100644 index 0000000..d65c632 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingController.java @@ -0,0 +1,56 @@ +package com.zioinfo.kintex.system.setting; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 시스템 설정 API (/api/admin/settings). ADMIN 게이트. 시크릿 값은 마스킹 응답. */ +@RestController +@RequestMapping("/api/admin/settings") +public class SettingController { + + private final SettingService service; + private final SystemAccessGuard guard; + + public SettingController(SettingService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.list()); + } + + @GetMapping("/{key}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String key) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.get(key)); + } + + @Audited(action = "SETTING_SAVE", targetType = "sys_setting") + @PostMapping + public ApiResponse save(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody SettingSaveRequest req) { + guard.requireAdmin(principal); + service.save(req, principal.userId()); + return ApiResponse.ok(null); + } + + @Audited(action = "SETTING_DELETE", targetType = "sys_setting") + @DeleteMapping("/{key}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String key) { + guard.requireAdmin(principal); + service.delete(key); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingDto.java new file mode 100644 index 0000000..c93b5f3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.system.setting; + +/** 시스템 설정 항목. secretYn=Y 이면 settingValue 는 마스킹되어 전달된다(원문 미노출). */ +public record SettingDto(String settingKey, String settingValue, String valueType, + String description, String secretYn) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingMapper.java new file mode 100644 index 0000000..56a4307 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingMapper.java @@ -0,0 +1,30 @@ +package com.zioinfo.kintex.system.setting; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 시스템 설정 매퍼. */ +@Mapper +public interface SettingMapper { + + @Select("SELECT setting_key AS settingKey, setting_value AS settingValue, value_type AS valueType, " + + "description, secret_yn AS secretYn FROM sys_setting ORDER BY setting_key") + List> findAll(); + + @Select("SELECT setting_key AS settingKey, setting_value AS settingValue, value_type AS valueType, " + + "description, secret_yn AS secretYn FROM sys_setting WHERE setting_key = #{key}") + Map findByKey(@Param("key") String key); + + @Insert("INSERT INTO sys_setting (setting_key, setting_value, value_type, description, secret_yn, updated_by) " + + "VALUES (#{settingKey}, #{settingValue}, COALESCE(#{valueType},'STRING'), #{description}, " + + "COALESCE(#{secretYn},'N'), #{updatedBy}) " + + "ON CONFLICT (setting_key) DO UPDATE SET setting_value=EXCLUDED.setting_value, " + + "value_type=EXCLUDED.value_type, description=EXCLUDED.description, secret_yn=EXCLUDED.secret_yn, " + + "updated_at=now(), updated_by=EXCLUDED.updated_by") + int upsert(Map p); + + @Delete("DELETE FROM sys_setting WHERE setting_key = #{key}") + int delete(@Param("key") String key); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingSaveRequest.java new file mode 100644 index 0000000..1b420b6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingSaveRequest.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.system.setting; + +import jakarta.validation.constraints.NotBlank; + +/** 설정 저장 요청. */ +public record SettingSaveRequest(@NotBlank String settingKey, String settingValue, + String valueType, String description, String secretYn) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingService.java new file mode 100644 index 0000000..a3b3500 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/setting/SettingService.java @@ -0,0 +1,62 @@ +package com.zioinfo.kintex.system.setting; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 시스템 설정 조회/변경. secret_yn=Y 항목은 응답에서 값을 마스킹한다(계약 §0-3, 시크릿 미노출). + * 시크릿 원문은 env 주입을 원칙으로 하며 DB 설정은 비-시크릿 운영값 위주. + */ +@Service +public class SettingService { + + private static final String MASK = "********"; + private final SettingMapper mapper; + + public SettingService(SettingMapper mapper) { + this.mapper = mapper; + } + + public List list() { + return mapper.findAll().stream().map(SettingService::toMaskedDto).toList(); + } + + public SettingDto get(String key) { + Map r = mapper.findByKey(key); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return toMaskedDto(r); + } + + public void save(SettingSaveRequest req, String actorId) { + Map p = new HashMap<>(); + p.put("settingKey", req.settingKey()); + p.put("settingValue", req.settingValue()); + p.put("valueType", req.valueType()); + p.put("description", req.description()); + p.put("secretYn", req.secretYn()); + p.put("updatedBy", actorId); + mapper.upsert(p); + } + + public void delete(String key) { + mapper.delete(key); + } + + private static SettingDto toMaskedDto(Map r) { + String secretYn = str(r.get("secretYn")); + String value = str(r.get("settingValue")); + if ("Y".equals(secretYn) && value != null) { + value = MASK; + } + return new SettingDto(str(r.get("settingKey")), value, str(r.get("valueType")), + str(r.get("description")), secretYn); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/PasswordResetRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/PasswordResetRequest.java new file mode 100644 index 0000000..3d7e318 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/PasswordResetRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.system.sysuser; + +import jakarta.validation.constraints.NotBlank; + +/** 관리자 비밀번호 초기화 — 새 비밀번호는 env/입력값. 응답·로그 노출 금지. */ +public record PasswordResetRequest(@NotBlank String newPassword) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserController.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserController.java new file mode 100644 index 0000000..599d980 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserController.java @@ -0,0 +1,88 @@ +package com.zioinfo.kintex.system.sysuser; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 시스템관리 — 사용자 관리 API (/api/admin/users). ADMIN 게이트. kintex-admin-dev 백오피스 공통 백엔드. */ +@RestController +@RequestMapping("/api/admin/users") +public class SysUserController { + + private final SysUserService service; + private final SystemAccessGuard guard; + + public SysUserController(SysUserService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String roleCode, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.list(keyword, status, roleCode, page, size)); + } + + @GetMapping("/{userId}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String userId) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.get(userId)); + } + + @Audited(action = "SYS_USER_CREATE", targetType = "app_user") + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody UserSaveRequest req) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.create(req)); + } + + @Audited(action = "SYS_USER_UPDATE", targetType = "app_user") + @PutMapping("/{userId}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String userId, + @Valid @RequestBody UserSaveRequest req) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.update(userId, req)); + } + + @Audited(action = "SYS_USER_STATUS", targetType = "app_user") + @PatchMapping("/{userId}/status") + public ApiResponse status(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String userId, @RequestParam String value) { + guard.requireAdmin(principal); + service.changeStatus(userId, value); + return ApiResponse.ok(null); + } + + @Audited(action = "SYS_USER_PW_RESET", targetType = "app_user") + @PostMapping("/{userId}/password") + public ApiResponse resetPassword(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String userId, + @Valid @RequestBody PasswordResetRequest req) { + guard.requireAdmin(principal); + service.resetPassword(userId, req.newPassword()); + return ApiResponse.ok(null); + } + + @Audited(action = "SYS_USER_OTP_RESET", targetType = "app_user") + @PostMapping("/{userId}/otp/reset") + public ApiResponse resetOtp(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String userId) { + guard.requireAdmin(principal); + service.resetOtp(userId); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserDto.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserDto.java new file mode 100644 index 0000000..ff27c59 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.system.sysuser; + +/** 시스템관리 사용자 카드 — 민감정보(해시·OTP 시크릿) 미포함. */ +public record SysUserDto( + String userId, String email, String displayName, boolean hallManager, + String roleCode, String deptId, String status, boolean otpEnabled, String lastLoginAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserMapper.java new file mode 100644 index 0000000..699b54e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserMapper.java @@ -0,0 +1,85 @@ +package com.zioinfo.kintex.system.sysuser; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * 시스템관리 — 사용자 조회/관리 매퍼. 보안 불변(계약 §0-3): password_hash·otp_secret 은 절대 select 안 함. + */ +@Mapper +public interface SysUserMapper { + + /** 전역 역할 코드(SystemAccessGuard 게이트). 없으면 null. */ + @Select("SELECT role_code FROM app_user WHERE id = #{userId}") + String findRoleCode(@Param("userId") String userId); + + /** 사용자 목록(검색·페이징) — 민감 컬럼 제외. */ + @Select(""" + + """) + List> search(@Param("keyword") String keyword, @Param("status") String status, + @Param("roleCode") String roleCode, + @Param("size") int size, @Param("offset") int offset); + + @Select(""" + + """) + long count(@Param("keyword") String keyword, @Param("status") String status, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT id AS userId, email, display_name AS displayName, hall_manager AS hallManager, + role_code AS roleCode, dept_id AS deptId, status, otp_enabled AS otpEnabled + FROM app_user WHERE id = #{userId} + """) + Map findById(@Param("userId") String userId); + + @org.apache.ibatis.annotations.Insert(""" + INSERT INTO app_user (id, email, display_name, password_hash, hall_manager, + role_code, dept_id, status, verify_method) + VALUES (#{userId}, #{email}, #{displayName}, #{passwordHash}, #{hallManager}, + #{roleCode}, #{deptId}, 'ACTIVE', 'EMAIL') + """) + int insert(Map p); + + @Update(""" + UPDATE app_user SET display_name = #{displayName}, role_code = #{roleCode}, + dept_id = #{deptId}, hall_manager = #{hallManager}, updated_at = now() + WHERE id = #{userId} + """) + int update(Map p); + + @Update("UPDATE app_user SET status = #{status}, updated_at = now() WHERE id = #{userId}") + int updateStatus(@Param("userId") String userId, @Param("status") String status); + + @Update("UPDATE app_user SET password_hash = #{passwordHash}, updated_at = now() WHERE id = #{userId}") + int updatePassword(@Param("userId") String userId, @Param("passwordHash") String passwordHash); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java new file mode 100644 index 0000000..cd50d3f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java @@ -0,0 +1,116 @@ +package com.zioinfo.kintex.system.sysuser; + +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.security.AccountSecurityMapper; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 시스템관리 — 사용자 관리 서비스. 비밀번호는 BCrypt 해시로만 저장하며 응답/로그 노출 금지(계약 §0-3). + * OTP 초기화는 보안 매퍼 재사용(uims 방식). + */ +@Service +public class SysUserService { + + private final SysUserMapper mapper; + private final AccountSecurityMapper securityMapper; + private final PasswordEncoder passwordEncoder; + + public SysUserService(SysUserMapper mapper, AccountSecurityMapper securityMapper, + PasswordEncoder passwordEncoder) { + this.mapper = mapper; + this.securityMapper = securityMapper; + this.passwordEncoder = passwordEncoder; + } + + public PageResponse list(String keyword, String status, String roleCode, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + List> rows = mapper.search(keyword, status, roleCode, s, p * s); + long total = mapper.count(keyword, status, roleCode); + return PageResponse.of(rows.stream().map(SysUserService::toDto).toList(), p, s, total); + } + + public SysUserDto get(String userId) { + Map row = mapper.findById(userId); + if (row == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return toDto(row); + } + + @Transactional + public SysUserDto create(UserSaveRequest req) { + if (req.email() == null || req.email().isBlank() || req.password() == null || req.password().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "이메일과 초기 비밀번호는 필수입니다."); + } + String id = "usr-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new HashMap<>(); + p.put("userId", id); + p.put("email", req.email().toLowerCase()); + p.put("displayName", req.displayName()); + p.put("passwordHash", passwordEncoder.encode(req.password())); + p.put("hallManager", Boolean.TRUE.equals(req.hallManager())); + p.put("roleCode", req.roleCode() == null ? "USER" : req.roleCode()); + p.put("deptId", req.deptId()); + mapper.insert(p); + return get(id); + } + + @Transactional + public SysUserDto update(String userId, UserSaveRequest req) { + Map p = new HashMap<>(); + p.put("userId", userId); + p.put("displayName", req.displayName()); + p.put("roleCode", req.roleCode() == null ? "USER" : req.roleCode()); + p.put("deptId", req.deptId()); + p.put("hallManager", Boolean.TRUE.equals(req.hallManager())); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return get(userId); + } + + @Transactional + public void changeStatus(String userId, String status) { + if (mapper.updateStatus(userId, status) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + @Transactional + public void resetPassword(String userId, String newPassword) { + if (newPassword == null || newPassword.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION, "새 비밀번호가 필요합니다."); + } + if (mapper.updatePassword(userId, passwordEncoder.encode(newPassword)) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + /** 관리자 OTP 초기화(uims 방식). */ + @Transactional + public void resetOtp(String userId) { + securityMapper.resetOtp(userId); + } + + private static SysUserDto toDto(Map r) { + return new SysUserDto( + str(r.get("userId")), str(r.get("email")), str(r.get("displayName")), + bool(r.get("hallManager")), str(r.get("roleCode")), str(r.get("deptId")), + str(r.get("status")), bool(r.get("otpEnabled")), str(r.get("lastLoginAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static boolean bool(Object o) { + if (o instanceof Boolean b) return b; + return o != null && Boolean.parseBoolean(String.valueOf(o)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/UserSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/UserSaveRequest.java new file mode 100644 index 0000000..190c11d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/UserSaveRequest.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.system.sysuser; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * 사용자 생성/수정 요청. 비밀번호는 생성 시에만 사용(BCrypt 해시 저장) — 응답·로그 노출 금지. + * roleCode: USER|MANAGER|ADMIN. hallManager: 킨텍스 내부 운영자 플래그. + */ +public record UserSaveRequest( + @Email String email, + @NotBlank String displayName, + String password, + String roleCode, + String deptId, + Boolean hallManager) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionDto.java new file mode 100644 index 0000000..b3a83f2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.work.meeting; + +/** 회의 액션아이템. */ +public record MeetingActionDto(String id, String meetingId, int seq, String actionItem, + String assigneeId, String dueDate, String status) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionRequest.java new file mode 100644 index 0000000..0907cef --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingActionRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.meeting; + +import jakarta.validation.constraints.NotBlank; + +/** 액션아이템 등록 요청. */ +public record MeetingActionRequest(@NotBlank String actionItem, String assigneeId, String dueDate) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java new file mode 100644 index 0000000..76d2e04 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingController.java @@ -0,0 +1,86 @@ +package com.zioinfo.kintex.work.meeting; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 회의 API (/api/work/meetings). 인증 사용자. 편집·회의록·삭제는 주최자(서비스에서 검증). */ +@RestController +@RequestMapping("/api/work/meetings") +public class MeetingController { + + private final MeetingService service; + private final EventAccessGuard guard; + + public MeetingController(MeetingService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(eventId, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody MeetingSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody MeetingSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.update(principal, id, req)); + } + + @PutMapping("/{id}/minutes") + public ApiResponse minutes(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, @RequestBody MinutesRequest req) { + guard.require(principal); + service.saveMinutes(id, req.minutes()); + return ApiResponse.ok(null); + } + + @PostMapping("/{id}/actions") + public ApiResponse addAction(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody MeetingActionRequest req) { + guard.require(principal); + return ApiResponse.ok(service.addAction(id, req)); + } + + @PatchMapping("/actions/{actionId}/status") + public ApiResponse actionStatus(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String actionId, @RequestParam String value) { + guard.require(principal); + service.updateActionStatus(actionId, value); + return ApiResponse.ok(null); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDetail.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDetail.java new file mode 100644 index 0000000..94c4a2e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDetail.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.meeting; + +import java.util.List; + +/** 회의 상세 — 본문 + 액션아이템. */ +public record MeetingDetail(MeetingDto meeting, List actions) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDto.java new file mode 100644 index 0000000..0936bb3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.meeting; + +/** 회의(WISE meeting 이식). minutes=회의록. */ +public record MeetingDto( + String id, String eventId, String title, String location, String meetingAt, + String organizerId, String organizerName, String content, String minutes, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java new file mode 100644 index 0000000..a0521d2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingMapper.java @@ -0,0 +1,84 @@ +package com.zioinfo.kintex.work.meeting; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 회의 매퍼(meeting + meeting_action). */ +@Mapper +public interface MeetingMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + + """) + long count(Map q); + + @Select(""" + SELECT id, event_id AS eventId, title, location, + to_char(meeting_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS meetingAt, + organizer_id AS organizerId, organizer_name AS organizerName, content, minutes, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM meeting WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Insert(""" + INSERT INTO meeting (id, event_id, title, location, meeting_at, organizer_id, organizer_name, content) + VALUES (#{id}, #{eventId}, #{title}, #{location}, CAST(#{meetingAt} AS timestamptz), + #{organizerId}, #{organizerName}, #{content}) + """) + int insert(Map p); + + @Update(""" + UPDATE meeting SET title=#{title}, location=#{location}, + meeting_at=CAST(#{meetingAt} AS timestamptz), content=#{content}, + event_id=#{eventId}, updated_at=now() + WHERE id=#{id} AND organizer_id=#{organizerId} + """) + int update(Map p); + + @Update("UPDATE meeting SET minutes=#{minutes}, updated_at=now() WHERE id=#{id}") + int updateMinutes(@Param("id") String id, @Param("minutes") String minutes); + + @Delete("DELETE FROM meeting WHERE id=#{id} AND organizer_id=#{organizerId}") + int delete(@Param("id") String id, @Param("organizerId") String organizerId); + + @Select(""" + SELECT id, meeting_id AS meetingId, seq, action_item AS actionItem, assignee_id AS assigneeId, + to_char(due_date,'YYYY-MM-DD') AS dueDate, status + FROM meeting_action WHERE meeting_id=#{meetingId} ORDER BY seq + """) + List> findActions(@Param("meetingId") String meetingId); + + @Select("SELECT COALESCE(MAX(seq),0) FROM meeting_action WHERE meeting_id=#{meetingId}") + int maxSeq(@Param("meetingId") String meetingId); + + @Insert(""" + INSERT INTO meeting_action (id, meeting_id, seq, action_item, assignee_id, due_date, status) + VALUES (#{id}, #{meetingId}, #{seq}, #{actionItem}, #{assigneeId}, + CAST(#{dueDate} AS date), 'TODO') + """) + int insertAction(Map p); + + @Update("UPDATE meeting_action SET status=#{status} WHERE id=#{id}") + int updateActionStatus(@Param("id") String id, @Param("status") String status); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSaveRequest.java new file mode 100644 index 0000000..ec23cb8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingSaveRequest.java @@ -0,0 +1,9 @@ +package com.zioinfo.kintex.work.meeting; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** 회의 저장 요청. meetingAt=ISO-8601. */ +public record MeetingSaveRequest(String eventId, @NotBlank String title, String location, + @NotNull String meetingAt, String content) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java new file mode 100644 index 0000000..2b442b0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MeetingService.java @@ -0,0 +1,134 @@ +package com.zioinfo.kintex.work.meeting; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 회의 서비스 — 회의·회의록·액션아이템. 편집/회의록/삭제는 주최자 본인. */ +@Service +public class MeetingService { + + private final MeetingMapper mapper; + + public MeetingService(MeetingMapper mapper) { + this.mapper = mapper; + } + + public PageResponse list(String eventId, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("eventId", eventId); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.search(q); + long total = mapper.count(q); + return PageResponse.of(rows.stream().map(MeetingService::toDto).toList(), p, s, total); + } + + public MeetingDetail get(String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + List actions = mapper.findActions(id).stream() + .map(MeetingService::toAction).toList(); + return new MeetingDetail(toDto(r), actions); + } + + @Transactional + public MeetingDto create(KintexPrincipal principal, MeetingSaveRequest req) { + String id = "mt-" + UUID.randomUUID().toString().substring(0, 12); + Map p = base(req); + p.put("id", id); + p.put("organizerId", principal.userId()); + p.put("organizerName", principal.displayName()); + mapper.insert(p); + return toDto(mapper.findById(id)); + } + + @Transactional + public MeetingDto update(KintexPrincipal principal, String id, MeetingSaveRequest req) { + Map p = base(req); + p.put("id", id); + p.put("organizerId", principal.userId()); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "주최자만 회의를 수정할 수 있습니다."); + } + return toDto(mapper.findById(id)); + } + + @Transactional + public void saveMinutes(String id, String minutes) { + if (mapper.updateMinutes(id, minutes) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + if (mapper.delete(id, principal.userId()) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "주최자만 회의를 삭제할 수 있습니다."); + } + } + + @Transactional + public MeetingActionDto addAction(String meetingId, MeetingActionRequest req) { + if (mapper.findById(meetingId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String id = "ma-" + UUID.randomUUID().toString().substring(0, 12); + int seq = mapper.maxSeq(meetingId) + 1; + Map p = new HashMap<>(); + p.put("id", id); + p.put("meetingId", meetingId); + p.put("seq", seq); + p.put("actionItem", req.actionItem()); + p.put("assigneeId", req.assigneeId()); + p.put("dueDate", req.dueDate()); + mapper.insertAction(p); + return mapper.findActions(meetingId).stream() + .filter(a -> id.equals(String.valueOf(a.get("id")))) + .map(MeetingService::toAction).findFirst().orElseThrow(); + } + + @Transactional + public void updateActionStatus(String actionId, String status) { + mapper.updateActionStatus(actionId, status); + } + + private static Map base(MeetingSaveRequest req) { + Map p = new HashMap<>(); + p.put("eventId", req.eventId()); + p.put("title", req.title()); + p.put("location", req.location()); + p.put("meetingAt", req.meetingAt()); + p.put("content", req.content()); + return p; + } + + private static MeetingDto toDto(Map r) { + return new MeetingDto(str(r.get("id")), str(r.get("eventId")), str(r.get("title")), + str(r.get("location")), str(r.get("meetingAt")), str(r.get("organizerId")), + str(r.get("organizerName")), str(r.get("content")), str(r.get("minutes")), + str(r.get("createdAt"))); + } + private static MeetingActionDto toAction(Map r) { + return new MeetingActionDto(str(r.get("id")), str(r.get("meetingId")), intVal(r.get("seq")), + str(r.get("actionItem")), str(r.get("assigneeId")), str(r.get("dueDate")), str(r.get("status"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java new file mode 100644 index 0000000..da4b9b7 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/meeting/MinutesRequest.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.work.meeting; + +/** 회의록 저장 요청. */ +public record MinutesRequest(String minutes) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageController.java new file mode 100644 index 0000000..a2d6a67 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageController.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.work.message; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** 쪽지 API (/api/work/messages). 인증 사용자. */ +@RestController +@RequestMapping("/api/work/messages") +public class MessageController { + + private final MessageService service; + private final EventAccessGuard guard; + + public MessageController(MessageService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping("/inbox") + public ApiResponse> inbox(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.inbox(principal, page, size)); + } + + @GetMapping("/sent") + public ApiResponse> sent(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.sent(principal, page, size)); + } + + @GetMapping("/unread-count") + public ApiResponse> unread(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + return ApiResponse.ok(Map.of("unread", service.unreadCount(principal))); + } + + @PostMapping + public ApiResponse> send(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody MessageSendRequest req) { + guard.require(principal); + return ApiResponse.ok(Map.of("id", service.send(principal, req))); + } + + @PostMapping("/{id}/read") + public ApiResponse read(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.markRead(principal, id); + return ApiResponse.ok(null); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageDto.java new file mode 100644 index 0000000..a47419d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.message; + +/** 쪽지(WISE message 이식). recvType(RECV/REF)·readAt 는 수신함 뷰에서 채워진다. */ +public record MessageDto( + String id, String senderId, String senderName, String title, String content, + String recvType, String readAt, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageMapper.java new file mode 100644 index 0000000..606a7a0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageMapper.java @@ -0,0 +1,55 @@ +package com.zioinfo.kintex.work.message; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 쪽지 매퍼(message + message_recipient). */ +@Mapper +public interface MessageMapper { + + @Insert("INSERT INTO message (id, sender_id, sender_name, title, content) " + + "VALUES (#{id}, #{senderId}, #{senderName}, #{title}, #{content})") + int insertMessage(Map p); + + @Insert("INSERT INTO message_recipient (id, message_id, recipient_id, recv_type) " + + "VALUES (#{id}, #{messageId}, #{recipientId}, #{recvType})") + int insertRecipient(Map p); + + @Select(""" + SELECT m.id, m.sender_id AS senderId, m.sender_name AS senderName, m.title, m.content, + r.recv_type AS recvType, + to_char(r.read_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS readAt, + to_char(m.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM message_recipient r JOIN message m ON m.id = r.message_id + WHERE r.recipient_id = #{userId} AND r.deleted = false + ORDER BY m.created_at DESC LIMIT #{size} OFFSET #{offset} + """) + List> inbox(@Param("userId") String userId, + @Param("size") int size, @Param("offset") int offset); + + @Select("SELECT count(*) FROM message_recipient WHERE recipient_id = #{userId} AND deleted = false") + long inboxCount(@Param("userId") String userId); + + @Select("SELECT count(*) FROM message_recipient WHERE recipient_id = #{userId} " + + "AND deleted = false AND read_at IS NULL") + long unreadCount(@Param("userId") String userId); + + @Select(""" + SELECT m.id, m.sender_id AS senderId, m.sender_name AS senderName, m.title, m.content, + to_char(m.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM message m WHERE m.sender_id = #{userId} + ORDER BY m.created_at DESC LIMIT #{size} OFFSET #{offset} + """) + List> sent(@Param("userId") String userId, + @Param("size") int size, @Param("offset") int offset); + + @Update("UPDATE message_recipient SET read_at = now() " + + "WHERE message_id = #{messageId} AND recipient_id = #{userId} AND read_at IS NULL") + int markRead(@Param("messageId") String messageId, @Param("userId") String userId); + + @Update("UPDATE message_recipient SET deleted = true " + + "WHERE message_id = #{messageId} AND recipient_id = #{userId}") + int deleteForRecipient(@Param("messageId") String messageId, @Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageSendRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageSendRequest.java new file mode 100644 index 0000000..9b64006 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageSendRequest.java @@ -0,0 +1,13 @@ +package com.zioinfo.kintex.work.message; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** 쪽지 발송 요청. recipientIds=수신(RECV), refIds=참조(REF, MSG_RCV_TYPE). */ +public record MessageSendRequest( + String title, + String content, + @NotEmpty List recipientIds, + List refIds) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageService.java new file mode 100644 index 0000000..ddf237b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/message/MessageService.java @@ -0,0 +1,94 @@ +package com.zioinfo.kintex.work.message; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 쪽지 서비스 — 발송(수신/참조), 수신함/발신함, 읽음 처리, 삭제(수신자 소프트). */ +@Service +public class MessageService { + + private final MessageMapper mapper; + + public MessageService(MessageMapper mapper) { + this.mapper = mapper; + } + + @Transactional + public String send(KintexPrincipal principal, MessageSendRequest req) { + String id = "msg-" + UUID.randomUUID().toString().substring(0, 12); + Map m = new HashMap<>(); + m.put("id", id); + m.put("senderId", principal.userId()); + m.put("senderName", principal.displayName()); + m.put("title", req.title()); + m.put("content", req.content()); + mapper.insertMessage(m); + addRecipients(id, req.recipientIds(), "RECV"); + addRecipients(id, req.refIds(), "REF"); + return id; + } + + private void addRecipients(String messageId, List ids, String recvType) { + if (ids == null) { + return; + } + for (String rid : ids) { + if (rid == null || rid.isBlank()) { + continue; + } + Map r = new HashMap<>(); + r.put("id", "mr-" + UUID.randomUUID().toString().substring(0, 12)); + r.put("messageId", messageId); + r.put("recipientId", rid); + r.put("recvType", recvType); + mapper.insertRecipient(r); + } + } + + public PageResponse inbox(KintexPrincipal principal, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + List> rows = mapper.inbox(principal.userId(), s, p * s); + long total = mapper.inboxCount(principal.userId()); + return PageResponse.of(rows.stream().map(MessageService::toDto).toList(), p, s, total); + } + + public PageResponse sent(KintexPrincipal principal, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + List> rows = mapper.sent(principal.userId(), s, p * s); + return PageResponse.of(rows.stream().map(MessageService::toDto).toList(), p, s, rows.size()); + } + + public long unreadCount(KintexPrincipal principal) { + return mapper.unreadCount(principal.userId()); + } + + @Transactional + public void markRead(KintexPrincipal principal, String messageId) { + mapper.markRead(messageId, principal.userId()); + } + + @Transactional + public void delete(KintexPrincipal principal, String messageId) { + if (mapper.deleteForRecipient(messageId, principal.userId()) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + private static MessageDto toDto(Map r) { + return new MessageDto(str(r.get("id")), str(r.get("senderId")), str(r.get("senderName")), + str(r.get("title")), str(r.get("content")), str(r.get("recvType")), + str(r.get("readAt")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeController.java new file mode 100644 index 0000000..df52af5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeController.java @@ -0,0 +1,71 @@ +package com.zioinfo.kintex.work.notice; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 공지 API (/api/work/notices). 조회=인증, 쓰기=매니저 이상. */ +@RestController +@RequestMapping("/api/work/notices") +public class NoticeController { + + private final NoticeService service; + private final EventAccessGuard authGuard; + private final SystemAccessGuard mgrGuard; + + public NoticeController(NoticeService service, EventAccessGuard authGuard, SystemAccessGuard mgrGuard) { + this.service = service; + this.authGuard = authGuard; + this.mgrGuard = mgrGuard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + authGuard.require(principal); + return ApiResponse.ok(service.list(eventId, category, keyword, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + authGuard.require(principal); + return ApiResponse.ok(service.get(id)); + } + + @Audited(action = "NOTICE_CREATE", targetType = "notice") + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody NoticeSaveRequest req) { + mgrGuard.requireManager(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @Audited(action = "NOTICE_UPDATE", targetType = "notice") + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody NoticeSaveRequest req) { + mgrGuard.requireManager(principal); + return ApiResponse.ok(service.update(id, req)); + } + + @Audited(action = "NOTICE_DELETE", targetType = "notice") + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + mgrGuard.requireManager(principal); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeDto.java new file mode 100644 index 0000000..95da5f1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.notice; + +/** 공지(WISE notice 이식). */ +public record NoticeDto( + String id, String eventId, String category, String title, String content, boolean pinned, + String authorId, String authorName, int viewCount, String publishedAt, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeMapper.java new file mode 100644 index 0000000..e48263c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeMapper.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.work.notice; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 공지 매퍼. */ +@Mapper +public interface NoticeMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + + """) + long count(Map q); + + @Select(""" + SELECT id, event_id AS eventId, category, title, content, pinned, + author_id AS authorId, author_name AS authorName, view_count AS viewCount, + to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS publishedAt, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM notice WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Update("UPDATE notice SET view_count = view_count + 1 WHERE id = #{id}") + int incrementView(@Param("id") String id); + + @Insert(""" + INSERT INTO notice (id, event_id, category, title, content, pinned, author_id, author_name, + published_at) + VALUES (#{id}, #{eventId}, COALESCE(#{category},'GENERAL'), #{title}, #{content}, + COALESCE(#{pinned},false), #{authorId}, #{authorName}, now()) + """) + int insert(Map p); + + @Update(""" + UPDATE notice SET category=#{category}, title=#{title}, content=#{content}, + pinned=COALESCE(#{pinned},false), event_id=#{eventId}, updated_at=now() + WHERE id = #{id} + """) + int update(Map p); + + @Delete("DELETE FROM notice WHERE id = #{id}") + int delete(@Param("id") String id); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeSaveRequest.java new file mode 100644 index 0000000..fac21c3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeSaveRequest.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.notice; + +import jakarta.validation.constraints.NotBlank; + +/** 공지 저장 요청. */ +public record NoticeSaveRequest(String eventId, String category, @NotBlank String title, + String content, Boolean pinned) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeService.java new file mode 100644 index 0000000..1a4a5c1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notice/NoticeService.java @@ -0,0 +1,104 @@ +package com.zioinfo.kintex.work.notice; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 공지 서비스. 조회는 전체 인증 사용자, 작성/수정/삭제는 매니저·관리자(컨트롤러에서 게이트). */ +@Service +public class NoticeService { + + private final NoticeMapper mapper; + + public NoticeService(NoticeMapper mapper) { + this.mapper = mapper; + } + + public PageResponse list(String eventId, String category, String keyword, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("eventId", eventId); + q.put("category", category); + q.put("keyword", keyword); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.search(q); + long total = mapper.count(q); + return PageResponse.of(rows.stream().map(NoticeService::toDto).toList(), p, s, total); + } + + @Transactional + public NoticeDto get(String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + mapper.incrementView(id); + return toDto(r); + } + + @Transactional + public NoticeDto create(KintexPrincipal principal, NoticeSaveRequest req) { + String id = "nt-" + UUID.randomUUID().toString().substring(0, 12); + Map p = base(req); + p.put("id", id); + p.put("authorId", principal.userId()); + p.put("authorName", principal.displayName()); + mapper.insert(p); + Map r = mapper.findById(id); + return toDto(r); + } + + @Transactional + public NoticeDto update(String id, NoticeSaveRequest req) { + Map p = base(req); + p.put("id", id); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return toDto(mapper.findById(id)); + } + + @Transactional + public void delete(String id) { + if (mapper.delete(id) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + private static Map base(NoticeSaveRequest req) { + Map p = new HashMap<>(); + p.put("eventId", req.eventId()); + p.put("category", req.category()); + p.put("title", req.title()); + p.put("content", req.content()); + p.put("pinned", req.pinned()); + return p; + } + + private static NoticeDto toDto(Map r) { + return new NoticeDto(str(r.get("id")), str(r.get("eventId")), str(r.get("category")), + str(r.get("title")), str(r.get("content")), bool(r.get("pinned")), + str(r.get("authorId")), str(r.get("authorName")), intVal(r.get("viewCount")), + str(r.get("publishedAt")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static boolean bool(Object o) { + if (o instanceof Boolean b) return b; + return o != null && Boolean.parseBoolean(String.valueOf(o)); + } + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + try { return o == null ? 0 : Integer.parseInt(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationController.java new file mode 100644 index 0000000..7e08b34 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationController.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.work.notification; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** 알림 API (/api/work/notifications). 인증 사용자(본인 알림). */ +@RestController +@RequestMapping("/api/work/notifications") +public class NotificationController { + + private final NotificationService service; + private final EventAccessGuard guard; + + public NotificationController(NotificationService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(defaultValue = "false") boolean unreadOnly, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(principal, unreadOnly, page, size)); + } + + @GetMapping("/unread-count") + public ApiResponse> unread(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + return ApiResponse.ok(Map.of("unread", service.unreadCount(principal))); + } + + @PostMapping("/{id}/read") + public ApiResponse read(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.markRead(principal, id); + return ApiResponse.ok(null); + } + + @PostMapping("/read-all") + public ApiResponse readAll(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + service.markAllRead(principal); + return ApiResponse.ok(null); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationDto.java new file mode 100644 index 0000000..9a98692 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.work.notification; + +/** 알림(WISE notification 이식). */ +public record NotificationDto(String id, String recipientId, String eventId, String notiType, + String title, String message, String link, String readAt, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationMapper.java new file mode 100644 index 0000000..2de428a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationMapper.java @@ -0,0 +1,45 @@ +package com.zioinfo.kintex.work.notification; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 알림 매퍼. */ +@Mapper +public interface NotificationMapper { + + @Select(""" + + """) + List> listForUser(Map q); + + @Select("SELECT count(*) FROM notification WHERE recipient_id = #{userId} AND read_at IS NULL") + long unreadCount(@Param("userId") String userId); + + @Insert(""" + INSERT INTO notification (id, recipient_id, event_id, noti_type, title, message, link) + VALUES (#{id}, #{recipientId}, #{eventId}, COALESCE(#{notiType},'SYSTEM'), #{title}, + #{message}, #{link}) + """) + int insert(Map p); + + @Update("UPDATE notification SET read_at = now() WHERE id = #{id} AND recipient_id = #{userId} " + + "AND read_at IS NULL") + int markRead(@Param("id") String id, @Param("userId") String userId); + + @Update("UPDATE notification SET read_at = now() WHERE recipient_id = #{userId} AND read_at IS NULL") + int markAllRead(@Param("userId") String userId); + + @Delete("DELETE FROM notification WHERE id = #{id} AND recipient_id = #{userId}") + int delete(@Param("id") String id, @Param("userId") String userId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationService.java new file mode 100644 index 0000000..a8cab45 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/notification/NotificationService.java @@ -0,0 +1,82 @@ +package com.zioinfo.kintex.work.notification; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 알림 서비스 — 발송 채널 단일화(§7-6). 도메인 모듈은 직접 발송 대신 {@link #notify}를 호출한다. + * (WebSocket 실시간 푸시는 config.WebSocketConfig 토픽으로 별도 배선.) + */ +@Service +public class NotificationService { + + private final NotificationMapper mapper; + + public NotificationService(NotificationMapper mapper) { + this.mapper = mapper; + } + + /** 공통 알림 발행(도메인 모듈용). NOTI_TYPE: APPROVAL/DEADLINE/MESSAGE/SYSTEM. */ + @Transactional + public String notify(String recipientId, String eventId, String notiType, + String title, String message, String link) { + String id = "no-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new HashMap<>(); + p.put("id", id); + p.put("recipientId", recipientId); + p.put("eventId", eventId); + p.put("notiType", notiType); + p.put("title", title); + p.put("message", message); + p.put("link", link); + mapper.insert(p); + return id; + } + + public PageResponse list(KintexPrincipal principal, boolean unreadOnly, + int page, int size) { + int pg = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("userId", principal.userId()); + q.put("unreadOnly", unreadOnly); + q.put("size", s); + q.put("offset", pg * s); + List> rows = mapper.listForUser(q); + long total = unreadOnly ? mapper.unreadCount(principal.userId()) : rows.size(); + return PageResponse.of(rows.stream().map(NotificationService::toDto).toList(), pg, s, total); + } + + public long unreadCount(KintexPrincipal principal) { + return mapper.unreadCount(principal.userId()); + } + + @Transactional + public void markRead(KintexPrincipal principal, String id) { + mapper.markRead(id, principal.userId()); + } + + @Transactional + public void markAllRead(KintexPrincipal principal) { + mapper.markAllRead(principal.userId()); + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + mapper.delete(id, principal.userId()); + } + + private static NotificationDto toDto(Map r) { + return new NotificationDto(str(r.get("id")), str(r.get("recipientId")), str(r.get("eventId")), + str(r.get("notiType")), str(r.get("title")), str(r.get("message")), str(r.get("link")), + str(r.get("readAt")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentDto.java new file mode 100644 index 0000000..f22e70f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.work.opinion; + +/** 의견 답변/댓글. */ +public record OpinionCommentDto(String id, String opinionId, String authorId, String authorName, + String content, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentRequest.java new file mode 100644 index 0000000..9fe1416 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionCommentRequest.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.opinion; + +import jakarta.validation.constraints.NotBlank; + +/** 의견 답변 요청. */ +public record OpinionCommentRequest(@NotBlank String content) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionController.java new file mode 100644 index 0000000..1e8b0eb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionController.java @@ -0,0 +1,77 @@ +package com.zioinfo.kintex.work.opinion; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.system.SystemAccessGuard; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 의견 API (/api/work/opinions). 조회/등록=인증, 상태변경/답변=매니저. */ +@RestController +@RequestMapping("/api/work/opinions") +public class OpinionController { + + private final OpinionService service; + private final EventAccessGuard authGuard; + private final SystemAccessGuard mgrGuard; + + public OpinionController(OpinionService service, EventAccessGuard authGuard, SystemAccessGuard mgrGuard) { + this.service = service; + this.authGuard = authGuard; + this.mgrGuard = mgrGuard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + authGuard.require(principal); + return ApiResponse.ok(service.list(eventId, status, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + authGuard.require(principal); + return ApiResponse.ok(service.get(principal, id)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody OpinionSaveRequest req) { + authGuard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @Audited(action = "OPINION_STATUS", targetType = "opinion") + @PatchMapping("/{id}/status") + public ApiResponse status(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, @RequestParam String value) { + mgrGuard.requireManager(principal); + service.changeStatus(id, value); + return ApiResponse.ok(null); + } + + @Audited(action = "OPINION_COMMENT", targetType = "opinion_comment") + @PostMapping("/{id}/comments") + public ApiResponse comment(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody OpinionCommentRequest req) { + mgrGuard.requireManager(principal); + return ApiResponse.ok(service.addComment(principal, id, req.content())); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + authGuard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDetail.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDetail.java new file mode 100644 index 0000000..18b62bd --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDetail.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.opinion; + +import java.util.List; + +/** 의견 상세 — 본문 + 답변 목록. */ +public record OpinionDetail(OpinionDto opinion, List comments) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDto.java new file mode 100644 index 0000000..243b392 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionDto.java @@ -0,0 +1,7 @@ +package com.zioinfo.kintex.work.opinion; + +/** 의견/건의(WISE opinion 이식). */ +public record OpinionDto( + String id, String eventId, String category, String title, String content, String status, + String authorId, String authorName, String secretYn, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionMapper.java new file mode 100644 index 0000000..4583a84 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionMapper.java @@ -0,0 +1,71 @@ +package com.zioinfo.kintex.work.opinion; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 의견 매퍼(opinion + opinion_comment). */ +@Mapper +public interface OpinionMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + + """) + long count(Map q); + + @Select(""" + SELECT id, event_id AS eventId, category, title, content, status, + author_id AS authorId, author_name AS authorName, secret_yn AS secretYn, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM opinion WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Insert(""" + INSERT INTO opinion (id, event_id, category, title, content, status, author_id, author_name, secret_yn) + VALUES (#{id}, #{eventId}, #{category}, #{title}, #{content}, 'OPEN', + #{authorId}, #{authorName}, COALESCE(#{secretYn},'N')) + """) + int insert(Map p); + + @Update("UPDATE opinion SET status = #{status}, updated_at = now() WHERE id = #{id}") + int updateStatus(@Param("id") String id, @Param("status") String status); + + @Delete("DELETE FROM opinion WHERE id = #{id} AND author_id = #{authorId}") + int delete(@Param("id") String id, @Param("authorId") String authorId); + + @Select(""" + SELECT id, opinion_id AS opinionId, author_id AS authorId, author_name AS authorName, content, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM opinion_comment WHERE opinion_id = #{opinionId} ORDER BY created_at + """) + List> findComments(@Param("opinionId") String opinionId); + + @Insert(""" + INSERT INTO opinion_comment (id, opinion_id, author_id, author_name, content) + VALUES (#{id}, #{opinionId}, #{authorId}, #{authorName}, #{content}) + """) + int insertComment(Map p); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionSaveRequest.java new file mode 100644 index 0000000..414d8ca --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionSaveRequest.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.opinion; + +import jakarta.validation.constraints.NotBlank; + +/** 의견 등록 요청. secretYn=Y 면 작성자·매니저만 열람. */ +public record OpinionSaveRequest(String eventId, String category, @NotBlank String title, + String content, String secretYn) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionService.java new file mode 100644 index 0000000..b6aa0dd --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/opinion/OpinionService.java @@ -0,0 +1,115 @@ +package com.zioinfo.kintex.work.opinion; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 의견 서비스 — 비밀글은 작성자·매니저만 열람. 상태변경/답변은 매니저. */ +@Service +public class OpinionService { + + private final OpinionMapper mapper; + private final SystemAccessGuard scope; + + public OpinionService(OpinionMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public PageResponse list(String eventId, String status, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("eventId", eventId); + q.put("status", status); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.search(q); + long total = mapper.count(q); + return PageResponse.of(rows.stream().map(OpinionService::toDto).toList(), p, s, total); + } + + public OpinionDetail get(KintexPrincipal principal, String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + boolean secret = "Y".equals(str(r.get("secretYn"))); + boolean owner = principal.userId().equals(str(r.get("authorId"))); + if (secret && !owner && !scope.canSeeAll(principal)) { + throw new ApiException(ErrorCode.FORBIDDEN, "비밀 의견은 작성자와 담당자만 볼 수 있습니다."); + } + List comments = mapper.findComments(id).stream() + .map(OpinionService::toComment).toList(); + return new OpinionDetail(toDto(r), comments); + } + + @Transactional + public OpinionDto create(KintexPrincipal principal, OpinionSaveRequest req) { + String id = "op-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new HashMap<>(); + p.put("id", id); + p.put("eventId", req.eventId()); + p.put("category", req.category()); + p.put("title", req.title()); + p.put("content", req.content()); + p.put("secretYn", req.secretYn()); + p.put("authorId", principal.userId()); + p.put("authorName", principal.displayName()); + mapper.insert(p); + return toDto(mapper.findById(id)); + } + + @Transactional + public void changeStatus(String id, String status) { + if (mapper.updateStatus(id, status) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + if (mapper.delete(id, principal.userId()) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인 의견만 삭제할 수 있습니다."); + } + } + + @Transactional + public OpinionCommentDto addComment(KintexPrincipal principal, String opinionId, String content) { + if (mapper.findById(opinionId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String id = "oc-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new HashMap<>(); + p.put("id", id); + p.put("opinionId", opinionId); + p.put("authorId", principal.userId()); + p.put("authorName", principal.displayName()); + p.put("content", content); + mapper.insertComment(p); + return mapper.findComments(opinionId).stream() + .filter(c -> id.equals(String.valueOf(c.get("id")))) + .map(OpinionService::toComment).findFirst().orElseThrow(); + } + + private static OpinionDto toDto(Map r) { + return new OpinionDto(str(r.get("id")), str(r.get("eventId")), str(r.get("category")), + str(r.get("title")), str(r.get("content")), str(r.get("status")), + str(r.get("authorId")), str(r.get("authorName")), str(r.get("secretYn")), + str(r.get("createdAt"))); + } + private static OpinionCommentDto toComment(Map r) { + return new OpinionCommentDto(str(r.get("id")), str(r.get("opinionId")), str(r.get("authorId")), + str(r.get("authorName")), str(r.get("content")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportController.java new file mode 100644 index 0000000..fc8d8bd --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportController.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.work.report; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 보고서 API (/api/work/reports). 인증 사용자. */ +@RestController +@RequestMapping("/api/work/reports") +public class ReportController { + + private final ReportService service; + private final EventAccessGuard guard; + + public ReportController(ReportService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String reportType, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(principal, eventId, reportType, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.get(principal, id)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody ReportSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody ReportSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.update(principal, id, req)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportDto.java new file mode 100644 index 0000000..458a273 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportDto.java @@ -0,0 +1,6 @@ +package com.zioinfo.kintex.work.report; + +/** 보고서(WISE report 이식). */ +public record ReportDto(String id, String eventId, String reportType, String periodFrom, String periodTo, + String title, String content, String authorId, String authorName, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportMapper.java new file mode 100644 index 0000000..772d530 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportMapper.java @@ -0,0 +1,68 @@ +package com.zioinfo.kintex.work.report; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 보고서 매퍼. */ +@Mapper +public interface ReportMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + + """) + long count(Map q); + + @Select(""" + SELECT id, event_id AS eventId, report_type AS reportType, + to_char(period_from,'YYYY-MM-DD') AS periodFrom, to_char(period_to,'YYYY-MM-DD') AS periodTo, + title, content, author_id AS authorId, author_name AS authorName, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM report WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Insert(""" + INSERT INTO report (id, event_id, report_type, period_from, period_to, title, content, + author_id, author_name) + VALUES (#{id}, #{eventId}, COALESCE(#{reportType},'DAILY'), CAST(#{periodFrom} AS date), + CAST(#{periodTo} AS date), #{title}, #{content}, #{authorId}, #{authorName}) + """) + int insert(Map p); + + @Update(""" + UPDATE report SET report_type=#{reportType}, period_from=CAST(#{periodFrom} AS date), + period_to=CAST(#{periodTo} AS date), title=#{title}, content=#{content}, + event_id=#{eventId}, updated_at=now() + WHERE id=#{id} AND author_id=#{authorId} + """) + int update(Map p); + + @Delete("DELETE FROM report WHERE id=#{id} AND author_id=#{authorId}") + int delete(@Param("id") String id, @Param("authorId") String authorId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportSaveRequest.java new file mode 100644 index 0000000..efd66c5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportSaveRequest.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.report; + +import jakarta.validation.constraints.NotBlank; + +/** 보고서 저장 요청. reportType: DAILY|WEEKLY|MONTHLY. period=YYYY-MM-DD. */ +public record ReportSaveRequest(String eventId, String reportType, String periodFrom, String periodTo, + @NotBlank String title, String content) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportService.java new file mode 100644 index 0000000..75b2f60 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/report/ReportService.java @@ -0,0 +1,101 @@ +package com.zioinfo.kintex.work.report; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 보고서 서비스. 일반 사용자는 본인 보고서, 매니저/관리자는 전체 열람. 편집은 작성자 본인. */ +@Service +public class ReportService { + + private final ReportMapper mapper; + private final SystemAccessGuard scope; + + public ReportService(ReportMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public PageResponse list(KintexPrincipal principal, String eventId, String reportType, + int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("mineOnly", !scope.canSeeAll(principal)); + q.put("userId", principal.userId()); + q.put("eventId", eventId); + q.put("reportType", reportType); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.search(q); + long total = mapper.count(q); + return PageResponse.of(rows.stream().map(ReportService::toDto).toList(), p, s, total); + } + + public ReportDto get(KintexPrincipal principal, String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!scope.canSeeAll(principal) && !principal.userId().equals(str(r.get("authorId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return toDto(r); + } + + @Transactional + public ReportDto create(KintexPrincipal principal, ReportSaveRequest req) { + String id = "rp-" + UUID.randomUUID().toString().substring(0, 12); + Map p = base(req); + p.put("id", id); + p.put("authorId", principal.userId()); + p.put("authorName", principal.displayName()); + mapper.insert(p); + return get(principal, id); + } + + @Transactional + public ReportDto update(KintexPrincipal principal, String id, ReportSaveRequest req) { + Map p = base(req); + p.put("id", id); + p.put("authorId", principal.userId()); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인 보고서만 수정할 수 있습니다."); + } + return get(principal, id); + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + if (mapper.delete(id, principal.userId()) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인 보고서만 삭제할 수 있습니다."); + } + } + + private static Map base(ReportSaveRequest req) { + Map p = new HashMap<>(); + p.put("eventId", req.eventId()); + p.put("reportType", req.reportType()); + p.put("periodFrom", req.periodFrom()); + p.put("periodTo", req.periodTo()); + p.put("title", req.title()); + p.put("content", req.content()); + return p; + } + + private static ReportDto toDto(Map r) { + return new ReportDto(str(r.get("id")), str(r.get("eventId")), str(r.get("reportType")), + str(r.get("periodFrom")), str(r.get("periodTo")), str(r.get("title")), + str(r.get("content")), str(r.get("authorId")), str(r.get("authorName")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleController.java new file mode 100644 index 0000000..0bcb7ed --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleController.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.work.schedule; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 일정 API (/api/work/schedules). 인증 사용자. */ +@RestController +@RequestMapping("/api/work/schedules") +public class ScheduleController { + + private final ScheduleService service; + private final EventAccessGuard guard; + + public ScheduleController(ScheduleService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String fromAt, + @RequestParam(required = false) String toAt) { + guard.require(principal); + return ApiResponse.ok(service.list(principal, eventId, fromAt, toAt)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.get(principal, id)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody ScheduleSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody ScheduleSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.update(principal, id, req)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleDto.java new file mode 100644 index 0000000..a8b7c25 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleDto.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.schedule; + +/** 일정(WISE schedule 이식). */ +public record ScheduleDto( + String id, String eventId, String ownerId, String ownerName, String title, + String scheduleType, String importance, String startAt, String endAt, boolean allDay, + String location, String content, String color, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleMapper.java new file mode 100644 index 0000000..c83f9ea --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleMapper.java @@ -0,0 +1,64 @@ +package com.zioinfo.kintex.work.schedule; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 일정 매퍼. 조회는 기간 범위 + DataScope. */ +@Mapper +public interface ScheduleMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + SELECT id, event_id AS eventId, owner_id AS ownerId, owner_name AS ownerName, title, + schedule_type AS scheduleType, importance, + to_char(start_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS startAt, + to_char(end_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS endAt, + all_day AS allDay, location, content, color, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt + FROM schedule WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Insert(""" + INSERT INTO schedule (id, event_id, owner_id, owner_name, title, schedule_type, importance, + start_at, end_at, all_day, location, content, color) + VALUES (#{id}, #{eventId}, #{ownerId}, #{ownerName}, #{title}, + COALESCE(#{scheduleType},'PERSONAL'), COALESCE(#{importance},'NORMAL'), + CAST(#{startAt} AS timestamptz), CAST(#{endAt} AS timestamptz), + COALESCE(#{allDay},false), #{location}, #{content}, #{color}) + """) + int insert(Map p); + + @Update(""" + UPDATE schedule SET title=#{title}, schedule_type=#{scheduleType}, importance=#{importance}, + start_at=CAST(#{startAt} AS timestamptz), end_at=CAST(#{endAt} AS timestamptz), + all_day=COALESCE(#{allDay},false), location=#{location}, content=#{content}, + color=#{color}, event_id=#{eventId}, updated_at=now() + WHERE id=#{id} AND owner_id=#{ownerId} + """) + int update(Map p); + + @Delete("DELETE FROM schedule WHERE id=#{id} AND owner_id=#{ownerId}") + int delete(@Param("id") String id, @Param("ownerId") String ownerId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleSaveRequest.java new file mode 100644 index 0000000..2daf9b2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleSaveRequest.java @@ -0,0 +1,18 @@ +package com.zioinfo.kintex.work.schedule; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** 일정 저장 요청. startAt/endAt 은 ISO-8601. */ +public record ScheduleSaveRequest( + String eventId, + @NotBlank String title, + String scheduleType, + String importance, + @NotNull String startAt, + String endAt, + Boolean allDay, + String location, + String content, + String color) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleService.java new file mode 100644 index 0000000..a26f82b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/schedule/ScheduleService.java @@ -0,0 +1,104 @@ +package com.zioinfo.kintex.work.schedule; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 일정 서비스. DataScope: 일반은 본인, 매니저/관리자는 전체. 편집은 소유자 본인. */ +@Service +public class ScheduleService { + + private final ScheduleMapper mapper; + private final SystemAccessGuard scope; + + public ScheduleService(ScheduleMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public List list(KintexPrincipal principal, String eventId, String fromAt, String toAt) { + Map q = new HashMap<>(); + q.put("mineOnly", !scope.canSeeAll(principal)); + q.put("userId", principal.userId()); + q.put("eventId", eventId); + q.put("fromAt", fromAt); + q.put("toAt", toAt); + return mapper.search(q).stream().map(ScheduleService::toDto).toList(); + } + + public ScheduleDto get(KintexPrincipal principal, String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!scope.canSeeAll(principal) && !principal.userId().equals(str(r.get("ownerId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return toDto(r); + } + + @Transactional + public ScheduleDto create(KintexPrincipal principal, ScheduleSaveRequest req) { + String id = "sc-" + UUID.randomUUID().toString().substring(0, 12); + Map p = base(req); + p.put("id", id); + p.put("ownerId", principal.userId()); + p.put("ownerName", principal.displayName()); + mapper.insert(p); + return get(principal, id); + } + + @Transactional + public ScheduleDto update(KintexPrincipal principal, String id, ScheduleSaveRequest req) { + Map p = base(req); + p.put("id", id); + p.put("ownerId", principal.userId()); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인 일정만 수정할 수 있습니다."); + } + return get(principal, id); + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + if (mapper.delete(id, principal.userId()) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인 일정만 삭제할 수 있습니다."); + } + } + + private static Map base(ScheduleSaveRequest req) { + Map p = new HashMap<>(); + p.put("eventId", req.eventId()); + p.put("title", req.title()); + p.put("scheduleType", req.scheduleType()); + p.put("importance", req.importance()); + p.put("startAt", req.startAt()); + p.put("endAt", req.endAt()); + p.put("allDay", req.allDay()); + p.put("location", req.location()); + p.put("content", req.content()); + p.put("color", req.color()); + return p; + } + + private static ScheduleDto toDto(Map r) { + return new ScheduleDto(str(r.get("id")), str(r.get("eventId")), str(r.get("ownerId")), + str(r.get("ownerName")), str(r.get("title")), str(r.get("scheduleType")), + str(r.get("importance")), str(r.get("startAt")), str(r.get("endAt")), + bool(r.get("allDay")), str(r.get("location")), str(r.get("content")), + str(r.get("color")), str(r.get("createdAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static boolean bool(Object o) { + if (o instanceof Boolean b) return b; + return o != null && Boolean.parseBoolean(String.valueOf(o)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java new file mode 100644 index 0000000..6420bb8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchController.java @@ -0,0 +1,31 @@ +package com.zioinfo.kintex.work.search; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 통합검색 API (/api/work/search). 인증 사용자. */ +@RestController +@RequestMapping("/api/work/search") +public class SearchController { + + private final SearchService service; + private final EventAccessGuard guard; + + public SearchController(SearchService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> search(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam("q") String q, + @RequestParam(defaultValue = "30") int limit) { + guard.require(principal); + return ApiResponse.ok(service.search(principal, q, limit)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchMapper.java new file mode 100644 index 0000000..f029d38 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchMapper.java @@ -0,0 +1,47 @@ +package com.zioinfo.kintex.work.search; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 통합검색 매퍼 — 공통 업무 테이블을 title 기준으로 검색(UNION). DataScope: + * seeAll 이 false 면 worklog/report/opinion 은 본인 것 + 비밀 아님만. + */ +@Mapper +public interface SearchMapper { + + @Select(""" + + """) + List> searchAll(@Param("q") String q, @Param("userId") String userId, + @Param("seeAll") boolean seeAll, @Param("size") int size); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java new file mode 100644 index 0000000..05c9634 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchResultItem.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.work.search; + +/** 통합검색 결과 항목. type: WORKLOG|NOTICE|MEETING|REPORT|OPINION. */ +public record SearchResultItem(String type, String id, String title, String createdAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java new file mode 100644 index 0000000..00bd187 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/search/SearchService.java @@ -0,0 +1,34 @@ +package com.zioinfo.kintex.work.search; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** 통합검색 서비스 — 공통 업무 데이터를 가로질러 title 검색. DataScope 적용. */ +@Service +public class SearchService { + + private final SearchMapper mapper; + private final SystemAccessGuard scope; + + public SearchService(SearchMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public List search(KintexPrincipal principal, String q, int limit) { + if (q == null || q.isBlank()) { + return List.of(); + } + int size = limit <= 0 ? 30 : Math.min(limit, 100); + boolean seeAll = scope.canSeeAll(principal); + List> rows = mapper.searchAll(q.trim(), principal.userId(), seeAll, size); + return rows.stream().map(r -> new SearchResultItem( + str(r.get("type")), str(r.get("id")), str(r.get("title")), str(r.get("createdAt")))).toList(); + } + + private static String str(Object o) { return o == null ? null : String.valueOf(o); } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatItem.java b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatItem.java new file mode 100644 index 0000000..24097a0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatItem.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.work.stats; + +/** 통계 집계 항목(코드/라벨 → 건수·시간). */ +public record StatItem(String name, long count, double hours) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsController.java new file mode 100644 index 0000000..494fa8b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsController.java @@ -0,0 +1,30 @@ +package com.zioinfo.kintex.work.stats; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 업무 통계 API (/api/work/stats). 인증 사용자. DataScope는 서비스에서 적용. */ +@RestController +@RequestMapping("/api/work/stats") +public class StatsController { + + private final StatsService service; + private final EventAccessGuard guard; + + public StatsController(StatsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping("/worklog") + public ApiResponse worklog(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String fromDate, + @RequestParam(required = false) String toDate) { + guard.require(principal); + return ApiResponse.ok(service.workStats(principal, eventId, fromDate, toDate)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsMapper.java new file mode 100644 index 0000000..0f44883 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsMapper.java @@ -0,0 +1,36 @@ +package com.zioinfo.kintex.work.stats; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** 통계 매퍼 — worklog 집계. DataScope: mineOnly 이면 writer 한정. */ +@Mapper +public interface StatsMapper { + + String FILTER = """ + AND writer_id = #{userId} + AND event_id = #{eventId} + AND work_date >= CAST(#{fromDate} AS date) + AND work_date <= CAST(#{toDate} AS date) + """; + + @Select("") + List> byStatus(Map q); + + @Select("") + List> byType(Map q); + + @Select("") + List> byWriter(Map q); + + @Select("") + Map summary(Map q); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsService.java new file mode 100644 index 0000000..3ea73bf --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/StatsService.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.work.stats; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 업무 통계 서비스 — worklog 집계(상태·유형·작성자). DataScope 적용. */ +@Service +public class StatsService { + + private final StatsMapper mapper; + private final SystemAccessGuard scope; + + public StatsService(StatsMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public WorkStatsDto workStats(KintexPrincipal principal, String eventId, String fromDate, String toDate) { + Map q = new HashMap<>(); + q.put("mineOnly", !scope.canSeeAll(principal)); + q.put("userId", principal.userId()); + q.put("eventId", eventId); + q.put("fromDate", fromDate); + q.put("toDate", toDate); + Map sum = mapper.summary(q); + long totalCount = lng(sum.get("count")); + double totalHours = dbl(sum.get("hours")); + return new WorkStatsDto(totalCount, totalHours, + toItems(mapper.byStatus(q)), toItems(mapper.byType(q)), toItems(mapper.byWriter(q))); + } + + private static List toItems(List> rows) { + return rows.stream().map(r -> new StatItem( + str(r.get("name")), lng(r.get("count")), dbl(r.get("hours")))).toList(); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static long lng(Object o) { + if (o instanceof Number n) return n.longValue(); + try { return o == null ? 0 : Long.parseLong(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } + private static double dbl(Object o) { + if (o instanceof Number n) return n.doubleValue(); + try { return o == null ? 0 : Double.parseDouble(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/stats/WorkStatsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/WorkStatsDto.java new file mode 100644 index 0000000..f4bcb6e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/stats/WorkStatsDto.java @@ -0,0 +1,9 @@ +package com.zioinfo.kintex.work.stats; + +import java.util.List; + +/** 업무 통계(WISE stats 이식) — 상태별·유형별·작성자별 집계 + 총계. */ +public record WorkStatsDto( + long totalCount, double totalHours, + List byStatus, List byType, List byWriter) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogController.java b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogController.java new file mode 100644 index 0000000..c5d2d11 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogController.java @@ -0,0 +1,66 @@ +package com.zioinfo.kintex.work.worklog; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 업무일지 API (/api/work/worklogs). 인증 사용자. DataScope는 서비스에서 적용. */ +@RestController +@RequestMapping("/api/work/worklogs") +public class WorklogController { + + private final WorklogService service; + private final EventAccessGuard guard; + + public WorklogController(WorklogService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String fromDate, + @RequestParam(required = false) String toDate, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(principal, eventId, fromDate, toDate, status, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.get(principal, id)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody WorklogSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + @PutMapping("/{id}") + public ApiResponse update(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody WorklogSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.update(principal, id, req)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + service.delete(principal, id); + return ApiResponse.ok(null); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogDto.java b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogDto.java new file mode 100644 index 0000000..c7498b1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogDto.java @@ -0,0 +1,8 @@ +package com.zioinfo.kintex.work.worklog; + +/** 업무일지(WISE worklog 이식). */ +public record WorklogDto( + String id, String eventId, String writerId, String writerName, String workDate, + String title, String workType, String progress, String status, String content, + double hours, String createdAt, String updatedAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogMapper.java new file mode 100644 index 0000000..1898f08 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogMapper.java @@ -0,0 +1,76 @@ +package com.zioinfo.kintex.work.worklog; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** 업무일지 매퍼. DataScope: mineOnly 이면 writer_id 한정. */ +@Mapper +public interface WorklogMapper { + + @Select(""" + + """) + List> search(Map q); + + @Select(""" + + """) + long count(Map q); + + @Select(""" + SELECT id, event_id AS eventId, writer_id AS writerId, writer_name AS writerName, + to_char(work_date,'YYYY-MM-DD') AS workDate, title, work_type AS workType, + progress, status, content, hours, + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt, + to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS updatedAt + FROM worklog WHERE id = #{id} + """) + Map findById(@Param("id") String id); + + @Insert(""" + INSERT INTO worklog (id, event_id, writer_id, writer_name, work_date, title, work_type, + progress, status, content, hours) + VALUES (#{id}, #{eventId}, #{writerId}, #{writerName}, CAST(#{workDate} AS date), #{title}, + COALESCE(#{workType},'OPERATION'), COALESCE(#{progress},'P0'), + COALESCE(#{status},'TODO'), #{content}, COALESCE(#{hours},0)) + """) + int insert(Map p); + + @Update(""" + UPDATE worklog SET work_date=CAST(#{workDate} AS date), title=#{title}, work_type=#{workType}, + progress=#{progress}, status=#{status}, content=#{content}, hours=#{hours}, + event_id=#{eventId}, updated_at=now() + WHERE id = #{id} AND writer_id = #{writerId} + """) + int update(Map p); + + @Delete("DELETE FROM worklog WHERE id = #{id} AND writer_id = #{writerId}") + int delete(@Param("id") String id, @Param("writerId") String writerId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogSaveRequest.java new file mode 100644 index 0000000..ed9941b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogSaveRequest.java @@ -0,0 +1,16 @@ +package com.zioinfo.kintex.work.worklog; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** 업무일지 저장 요청. */ +public record WorklogSaveRequest( + String eventId, + @NotNull String workDate, + @NotBlank String title, + String workType, + String progress, + String status, + String content, + Double hours) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogService.java new file mode 100644 index 0000000..af45555 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/work/worklog/WorklogService.java @@ -0,0 +1,111 @@ +package com.zioinfo.kintex.work.worklog; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** 업무일지 서비스. DataScope: 일반 사용자는 본인 것만, 매니저/관리자/홀매니저는 전체 열람. 편집은 작성자 본인만. */ +@Service +public class WorklogService { + + private final WorklogMapper mapper; + private final SystemAccessGuard scope; + + public WorklogService(WorklogMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public PageResponse list(KintexPrincipal principal, String eventId, String fromDate, + String toDate, String status, int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 20 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("mineOnly", !scope.canSeeAll(principal)); + q.put("userId", principal.userId()); + q.put("eventId", eventId); + q.put("fromDate", fromDate); + q.put("toDate", toDate); + q.put("status", status); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.search(q); + long total = mapper.count(q); + return PageResponse.of(rows.stream().map(WorklogService::toDto).toList(), p, s, total); + } + + public WorklogDto get(KintexPrincipal principal, String id) { + Map r = mapper.findById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!scope.canSeeAll(principal) && !principal.userId().equals(str(r.get("writerId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + return toDto(r); + } + + @Transactional + public WorklogDto create(KintexPrincipal principal, WorklogSaveRequest req) { + String id = "wl-" + UUID.randomUUID().toString().substring(0, 12); + Map p = base(req); + p.put("id", id); + p.put("writerId", principal.userId()); + p.put("writerName", principal.displayName()); + mapper.insert(p); + return get(principal, id); + } + + @Transactional + public WorklogDto update(KintexPrincipal principal, String id, WorklogSaveRequest req) { + Map p = base(req); + p.put("id", id); + p.put("writerId", principal.userId()); + if (mapper.update(p) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인이 작성한 업무일지만 수정할 수 있습니다."); + } + return get(principal, id); + } + + @Transactional + public void delete(KintexPrincipal principal, String id) { + if (mapper.delete(id, principal.userId()) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN, "본인이 작성한 업무일지만 삭제할 수 있습니다."); + } + } + + private static Map base(WorklogSaveRequest req) { + Map p = new HashMap<>(); + p.put("eventId", req.eventId()); + p.put("workDate", req.workDate()); + p.put("title", req.title()); + p.put("workType", req.workType()); + p.put("progress", req.progress()); + p.put("status", req.status()); + p.put("content", req.content()); + p.put("hours", req.hours()); + return p; + } + + private static WorklogDto toDto(Map r) { + return new WorklogDto(str(r.get("id")), str(r.get("eventId")), str(r.get("writerId")), + str(r.get("writerName")), str(r.get("workDate")), str(r.get("title")), + str(r.get("workType")), str(r.get("progress")), str(r.get("status")), + str(r.get("content")), dbl(r.get("hours")), str(r.get("createdAt")), str(r.get("updatedAt"))); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } + private static double dbl(Object o) { + if (o instanceof Number n) return n.doubleValue(); + try { return o == null ? 0 : Double.parseDouble(String.valueOf(o)); } + catch (NumberFormatException e) { return 0; } + } +} diff --git a/src/backend/src/main/resources/application.yml b/src/backend/src/main/resources/application.yml index 7ce4240..1543ae1 100644 --- a/src/backend/src/main/resources/application.yml +++ b/src/backend/src/main/resources/application.yml @@ -45,6 +45,19 @@ kintex: auth: jwt-secret: ${JWT_SECRET:CHANGE_ME_DEV_ONLY_kintex_local_secret_key_min_32_bytes} access-token-ttl-seconds: ${JWT_ACCESS_TTL:3600} + # 2차 인증(OTP)·로그인 실패 잠금·admin 시드 — 시크릿/비번은 env 주입만(하드코딩·admin123 시드 금지, 계약 §5B-3). + security: + lockout: + max-failed-attempts: ${LOGIN_MAX_FAILED:5} + lock-minutes: ${LOGIN_LOCK_MINUTES:15} + otp: + issuer: ${OTP_ISSUER:KINTEX} + # 최초 관리자 프로비저닝 — 값이 모두 주입될 때만 멱등 upsert. 미주입 시 시드 안 함(기본 관리자 계정 없음). + admin-seed: + enabled: ${ADMIN_SEED_ENABLED:false} + email: ${ADMIN_EMAIL:} + password: ${ADMIN_PASSWORD:} + display-name: ${ADMIN_DISPLAY_NAME:시스템관리자} rules: # 버전 관리되는 룰셋 데이터(규정·요율) — 코드가 아닌 데이터로 유지(킨텍스 규정 개정 대응) compliance-ruleset: classpath:rulesets/compliance-v1.json diff --git a/src/backend/src/main/resources/db/migration/V7__system_management_and_security.sql b/src/backend/src/main/resources/db/migration/V7__system_management_and_security.sql new file mode 100644 index 0000000..36732fc --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V7__system_management_and_security.sql @@ -0,0 +1,172 @@ +-- 킨텍스 Phase B(§5B) — 공통/시스템관리 레이어 스키마 (WISE/UIWS TB_* 이식, 멱등) +-- V1~V6 불변. 본 마이그레이션은 순증(additive)만 수행한다. +-- 보안 불변(계약 §0-3): password_hash·otp_secret·시크릿 설정값은 API 응답/로그 노출 금지. + +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS otp_enabled boolean NOT NULL DEFAULT false; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS verify_method varchar(10) NOT NULL DEFAULT 'EMAIL'; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS role_code varchar(20) NOT NULL DEFAULT 'USER'; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS dept_id varchar(40); +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS last_login_at timestamptz; + +CREATE TABLE IF NOT EXISTS common_code_group ( + grp_code varchar(40) PRIMARY KEY, + grp_name varchar(120) NOT NULL, + description varchar(400), + use_yn char(1) NOT NULL DEFAULT 'Y', + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS common_code ( + grp_code varchar(40) NOT NULL REFERENCES common_code_group(grp_code) ON DELETE CASCADE, + code varchar(80) NOT NULL, + code_name varchar(200) NOT NULL, + code_value varchar(400), + sort_order integer NOT NULL DEFAULT 0, + use_yn char(1) NOT NULL DEFAULT 'Y', + attr1 varchar(200), + description varchar(400), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (grp_code, code) +); +CREATE INDEX IF NOT EXISTS idx_common_code_grp ON common_code(grp_code, sort_order); + +CREATE TABLE IF NOT EXISTS sys_menu ( + id varchar(40) PRIMARY KEY, + parent_id varchar(40) REFERENCES sys_menu(id) ON DELETE CASCADE, + menu_name varchar(120) NOT NULL, + menu_path varchar(200), + icon varchar(60), + prg_type varchar(20) NOT NULL DEFAULT 'FORM', + required_role varchar(20), + sort_order integer NOT NULL DEFAULT 0, + use_yn char(1) NOT NULL DEFAULT 'Y', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sys_menu_parent ON sys_menu(parent_id, sort_order); + +CREATE TABLE IF NOT EXISTS sys_role ( + role_code varchar(20) PRIMARY KEY, + role_name varchar(120) NOT NULL, + description varchar(400), + use_yn char(1) NOT NULL DEFAULT 'Y', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS sys_permission ( + perm_code varchar(60) PRIMARY KEY, + perm_name varchar(120) NOT NULL, + description varchar(400), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS sys_role_permission ( + role_code varchar(20) NOT NULL REFERENCES sys_role(role_code) ON DELETE CASCADE, + perm_code varchar(60) NOT NULL REFERENCES sys_permission(perm_code) ON DELETE CASCADE, + PRIMARY KEY (role_code, perm_code) +); +CREATE TABLE IF NOT EXISTS sys_setting ( + setting_key varchar(120) PRIMARY KEY, + setting_value text, + value_type varchar(20) NOT NULL DEFAULT 'STRING', + description varchar(400), + secret_yn char(1) NOT NULL DEFAULT 'N', + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(40) +); +CREATE TABLE IF NOT EXISTS audit_log ( + id bigserial PRIMARY KEY, + actor_id varchar(40), + actor_name varchar(120), + action varchar(80) NOT NULL, + target_type varchar(60), + target_id varchar(80), + event_id varchar(40), + summary varchar(1000), + ruleset_version varchar(40), + result varchar(20) NOT NULL DEFAULT 'SUCCESS', + ip_hint varchar(60), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_event ON audit_log(event_id); + +INSERT INTO sys_role (role_code, role_name, description) VALUES + ('USER', '일반 사용자', '본인 데이터 범위'), + ('MANAGER', '매니저', '소속 팀 데이터 범위'), + ('ADMIN', '시스템관리자', '전체 데이터 + 시스템관리(백오피스)') +ON CONFLICT (role_code) DO UPDATE SET role_name = EXCLUDED.role_name, description = EXCLUDED.description; + +INSERT INTO sys_permission (perm_code, perm_name, description) VALUES + ('SYS_USER_MANAGE', '사용자 관리', '시스템관리 사용자 CRUD'), + ('ROLE_MANAGE', '역할/권한 관리', 'RBAC 역할·권한 매핑'), + ('CODE_MANAGE', '공통코드 관리', '코드그룹/코드 CRUD'), + ('MENU_MANAGE', '메뉴 관리', '메뉴 트리 CRUD'), + ('SETTING_MANAGE', '시스템설정 관리','설정 조회/변경'), + ('AUDIT_VIEW', '감사로그 조회', '감사 로그 열람') +ON CONFLICT (perm_code) DO UPDATE SET perm_name = EXCLUDED.perm_name; + +INSERT INTO sys_role_permission (role_code, perm_code) +SELECT 'ADMIN', perm_code FROM sys_permission +ON CONFLICT DO NOTHING; + +INSERT INTO common_code_group (grp_code, grp_name, description, sort_order) VALUES + ('USE_YN', '사용여부', '전 관리화면 공통', 1), + ('USER_ROLE', '전역 사용자 역할','USER/MANAGER/ADMIN 데이터 가시범위', 2), + ('VERIFY_METHOD', '2차검증 방식', 'EMAIL/OTP', 3), + ('PRG_TYPE', '프로그램 유형', 'FORM/POPUP', 4), + ('MSG_RCV_TYPE', '쪽지 수신구분', 'RECV/REF', 5), + ('WORK_STATUS', '업무 진행상태', 'worklog 이식', 6), + ('WORK_TYPE', '업무 유형', 'worklog 이식', 7), + ('WORK_PROGRESS', '업무 진척도', 'worklog 이식', 8), + ('SCHE_GUBUN', '일정 구분', 'schedule 이식', 9), + ('IMPORTANCE', '중요도', 'schedule/notice', 10), + ('NOTICE_TYPE', '공지 분류', 'notice 이식', 11), + ('OPINION_STATUS','의견 처리상태', 'opinion 이식', 12), + ('NOTI_TYPE', '알림 유형', 'notification 이식', 13), + ('REPORT_TYPE', '보고서 유형', 'report 이식', 14) +ON CONFLICT (grp_code) DO UPDATE SET grp_name = EXCLUDED.grp_name, description = EXCLUDED.description; + +INSERT INTO common_code (grp_code, code, code_name, sort_order) VALUES + ('USE_YN','Y','사용',1),('USE_YN','N','미사용',2), + ('USER_ROLE','USER','일반 사용자',1),('USER_ROLE','MANAGER','매니저',2),('USER_ROLE','ADMIN','시스템관리자',3), + ('VERIFY_METHOD','EMAIL','이메일 인증코드',1),('VERIFY_METHOD','OTP','OTP앱(TOTP)',2), + ('PRG_TYPE','FORM','폼',1),('PRG_TYPE','POPUP','팝업',2), + ('MSG_RCV_TYPE','RECV','수신',1),('MSG_RCV_TYPE','REF','참조',2), + ('WORK_STATUS','TODO','예정',1),('WORK_STATUS','DOING','진행중',2),('WORK_STATUS','DONE','완료',3),('WORK_STATUS','HOLD','보류',4), + ('WORK_TYPE','OPERATION','운영',1),('WORK_TYPE','SETUP','설치/시공',2),('WORK_TYPE','SUPPORT','지원',3),('WORK_TYPE','ETC','기타',4), + ('WORK_PROGRESS','P0','0%',1),('WORK_PROGRESS','P25','25%',2),('WORK_PROGRESS','P50','50%',3),('WORK_PROGRESS','P75','75%',4),('WORK_PROGRESS','P100','100%',5), + ('SCHE_GUBUN','PERSONAL','개인',1),('SCHE_GUBUN','TEAM','팀',2),('SCHE_GUBUN','EVENT','행사',3), + ('IMPORTANCE','LOW','낮음',1),('IMPORTANCE','NORMAL','보통',2),('IMPORTANCE','HIGH','높음',3),('IMPORTANCE','URGENT','긴급',4), + ('NOTICE_TYPE','GENERAL','일반',1),('NOTICE_TYPE','SYSTEM','시스템',2),('NOTICE_TYPE','EVENT','행사',3), + ('OPINION_STATUS','OPEN','접수',1),('OPINION_STATUS','REVIEW','검토중',2),('OPINION_STATUS','ANSWERED','답변완료',3),('OPINION_STATUS','CLOSED','종료',4), + ('NOTI_TYPE','APPROVAL','승인',1),('NOTI_TYPE','DEADLINE','마감',2),('NOTI_TYPE','MESSAGE','쪽지',3),('NOTI_TYPE','SYSTEM','시스템',4), + ('REPORT_TYPE','DAILY','일일',1),('REPORT_TYPE','WEEKLY','주간',2),('REPORT_TYPE','MONTHLY','월간',3) +ON CONFLICT (grp_code, code) DO UPDATE SET code_name = EXCLUDED.code_name, sort_order = EXCLUDED.sort_order; + +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, icon, sort_order, required_role) VALUES + ('m-sys', NULL, '시스템관리', NULL, 'settings', 90, 'ADMIN'), + ('m-sys-user', 'm-sys', '사용자 관리', '/admin/users', 'users', 1, 'ADMIN'), + ('m-sys-role', 'm-sys', '역할/권한', '/admin/roles', 'shield', 2, 'ADMIN'), + ('m-sys-code', 'm-sys', '공통코드', '/admin/codes', 'list', 3, 'ADMIN'), + ('m-sys-menu', 'm-sys', '메뉴 관리', '/admin/menus', 'menu', 4, 'ADMIN'), + ('m-sys-set', 'm-sys', '시스템설정', '/admin/settings', 'sliders', 5, 'ADMIN'), + ('m-sys-audit','m-sys', '감사로그', '/admin/audit', 'file-text', 6, 'ADMIN'), + ('m-work', NULL, '업무', NULL, 'briefcase', 10, NULL), + ('m-work-wl', 'm-work','업무일지', '/work/worklog', 'edit', 1, NULL), + ('m-work-sc', 'm-work','일정', '/work/schedule', 'calendar', 2, NULL), + ('m-work-msg', 'm-work','쪽지', '/work/message', 'mail', 3, NULL), + ('m-work-nt', 'm-work','공지', '/work/notice', 'bell', 4, NULL), + ('m-work-op', 'm-work','의견', '/work/opinion', 'message-circle', 5, NULL), + ('m-work-mt', 'm-work','회의', '/work/meeting', 'video', 6, NULL), + ('m-work-rp', 'm-work','보고서', '/work/report', 'bar-chart', 7, NULL), + ('m-work-st', 'm-work','통계', '/work/stats', 'pie-chart', 8, NULL) +ON CONFLICT (id) DO UPDATE SET menu_name = EXCLUDED.menu_name, menu_path = EXCLUDED.menu_path, + icon = EXCLUDED.icon, sort_order = EXCLUDED.sort_order, required_role = EXCLUDED.required_role; + +INSERT INTO sys_setting (setting_key, setting_value, value_type, description) VALUES + ('login.maxFailedAttempts', '5', 'INT', '로그인 실패 잠금 임계치'), + ('login.lockMinutes', '15', 'INT', '잠금 유지 시간(분)'), + ('otp.issuer', 'KINTEX', 'STRING', 'TOTP 발급자 표기'), + ('notice.pinnedLimit', '5', 'INT', '상단 고정 공지 최대 수') +ON CONFLICT (setting_key) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V8__common_work_modules.sql b/src/backend/src/main/resources/db/migration/V8__common_work_modules.sql new file mode 100644 index 0000000..3663c3f --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V8__common_work_modules.sql @@ -0,0 +1,154 @@ +-- 킨텍스 Phase B(§5B) — 공통 업무기능 모듈 스키마 (WISE/UIWS 이식, 멱등) +-- V1~V7 불변. worklog·schedule·message·notice·opinion·meeting·report·notification. +-- stats/search 는 별도 테이블 없이 아래 테이블을 집계/검색한다. + +CREATE TABLE IF NOT EXISTS worklog ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + writer_id varchar(40) NOT NULL, + writer_name varchar(120), + dept_id varchar(40), + work_date date NOT NULL, + title varchar(300) NOT NULL, + work_type varchar(20) NOT NULL DEFAULT 'OPERATION', + progress varchar(10) NOT NULL DEFAULT 'P0', + status varchar(20) NOT NULL DEFAULT 'TODO', + content text, + hours numeric(5,2) NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_worklog_writer_date ON worklog(writer_id, work_date DESC); +CREATE INDEX IF NOT EXISTS idx_worklog_event ON worklog(event_id, work_date DESC); + +CREATE TABLE IF NOT EXISTS schedule ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + owner_id varchar(40) NOT NULL, + owner_name varchar(120), + dept_id varchar(40), + title varchar(300) NOT NULL, + schedule_type varchar(20) NOT NULL DEFAULT 'PERSONAL', + importance varchar(20) NOT NULL DEFAULT 'NORMAL', + start_at timestamptz NOT NULL, + end_at timestamptz, + all_day boolean NOT NULL DEFAULT false, + location varchar(200), + content text, + color varchar(20), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_schedule_owner_start ON schedule(owner_id, start_at); +CREATE INDEX IF NOT EXISTS idx_schedule_event_start ON schedule(event_id, start_at); + +CREATE TABLE IF NOT EXISTS message ( + id varchar(40) PRIMARY KEY, + sender_id varchar(40) NOT NULL, + sender_name varchar(120), + title varchar(300), + content text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS message_recipient ( + id varchar(40) PRIMARY KEY, + message_id varchar(40) NOT NULL REFERENCES message(id) ON DELETE CASCADE, + recipient_id varchar(40) NOT NULL, + recv_type varchar(10) NOT NULL DEFAULT 'RECV', + read_at timestamptz, + deleted boolean NOT NULL DEFAULT false +); +CREATE INDEX IF NOT EXISTS idx_msg_recipient ON message_recipient(recipient_id, read_at); + +CREATE TABLE IF NOT EXISTS notice ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + category varchar(20) NOT NULL DEFAULT 'GENERAL', + title varchar(300) NOT NULL, + content text, + pinned boolean NOT NULL DEFAULT false, + author_id varchar(40) NOT NULL, + author_name varchar(120), + view_count integer NOT NULL DEFAULT 0, + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_notice_event ON notice(event_id, pinned DESC, created_at DESC); + +CREATE TABLE IF NOT EXISTS opinion ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + category varchar(40), + title varchar(300) NOT NULL, + content text, + status varchar(20) NOT NULL DEFAULT 'OPEN', + author_id varchar(40) NOT NULL, + author_name varchar(120), + secret_yn char(1) NOT NULL DEFAULT 'N', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_opinion_event ON opinion(event_id, created_at DESC); +CREATE TABLE IF NOT EXISTS opinion_comment ( + id varchar(40) PRIMARY KEY, + opinion_id varchar(40) NOT NULL REFERENCES opinion(id) ON DELETE CASCADE, + author_id varchar(40) NOT NULL, + author_name varchar(120), + content text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_opinion_comment ON opinion_comment(opinion_id, created_at); + +CREATE TABLE IF NOT EXISTS meeting ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + title varchar(300) NOT NULL, + location varchar(200), + meeting_at timestamptz NOT NULL, + organizer_id varchar(40) NOT NULL, + organizer_name varchar(120), + content text, + minutes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_meeting_event ON meeting(event_id, meeting_at DESC); +CREATE TABLE IF NOT EXISTS meeting_action ( + id varchar(40) PRIMARY KEY, + meeting_id varchar(40) NOT NULL REFERENCES meeting(id) ON DELETE CASCADE, + seq integer NOT NULL DEFAULT 1, + action_item varchar(500) NOT NULL, + assignee_id varchar(40), + due_date date, + status varchar(20) NOT NULL DEFAULT 'TODO' +); +CREATE INDEX IF NOT EXISTS idx_meeting_action ON meeting_action(meeting_id, seq); + +CREATE TABLE IF NOT EXISTS report ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), + report_type varchar(20) NOT NULL DEFAULT 'DAILY', + period_from date, + period_to date, + title varchar(300) NOT NULL, + content text, + author_id varchar(40) NOT NULL, + author_name varchar(120), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_report_event ON report(event_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS notification ( + id varchar(40) PRIMARY KEY, + recipient_id varchar(40) NOT NULL, + event_id varchar(40), + noti_type varchar(20) NOT NULL DEFAULT 'SYSTEM', + title varchar(300) NOT NULL, + message text, + link varchar(300), + read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification(recipient_id, read_at, created_at DESC); diff --git a/src/frontend/src/api/common.ts b/src/frontend/src/api/common.ts new file mode 100644 index 0000000..c4ae7dd --- /dev/null +++ b/src/frontend/src/api/common.ts @@ -0,0 +1,92 @@ +/* + * 공통/시스템관리 레이어 API 래퍼 (Phase B §5B) — 2차 인증(OTP)·공통코드·메뉴·알림. + * 백엔드 계약: /api/auth/otp/**, /api/common/**, /api/work/notifications/**. + */ +import { api } from './client'; +import type { LoginResponse, PageResponse } from './types'; + +// ── 2차 인증(OTP) ── +export interface SecureLoginResponse { + status: 'OK' | 'OTP_REQUIRED'; + challengeToken: string | null; + login: LoginResponse | null; +} +export interface OtpSetupResponse { + secret: string; + otpAuthUri: string; + qrImageDataUri: string; +} +export interface OtpStatusResponse { + otpEnabled: boolean; + verifyMethod: string; +} + +export const twoFactorApi = { + secureLogin: (email: string, password: string) => + api.post('/api/auth/login/secure', { email, password }, { anonymous: true }), + verifyOtp: (challengeToken: string, code: string) => + api.post('/api/auth/otp/verify', { challengeToken, code }, { anonymous: true }), + setup: () => api.post('/api/auth/otp/setup'), + confirm: (code: string) => api.post('/api/auth/otp/confirm', { code }), + status: () => api.get('/api/auth/otp/status'), + resetSelf: () => api.post('/api/auth/otp/reset'), +}; + +// ── 공통코드·메뉴 ── +export interface CodeGroupDto { + grpCode: string; + grpName: string; + description: string | null; + useYn: string; + sortOrder: number; +} +export interface CodeDto { + grpCode: string; + code: string; + codeName: string; + codeValue: string | null; + sortOrder: number; + useYn: string; + attr1: string | null; +} +export interface MenuDto { + id: string; + parentId: string | null; + menuName: string; + menuPath: string | null; + icon: string | null; + prgType: string; + requiredRole: string | null; + sortOrder: number; + useYn: string; + children: MenuDto[]; +} + +export const commonApi = { + codeGroups: () => api.get('/api/common/code-groups'), + codes: (grpCode: string) => api.get(`/api/common/codes/${encodeURIComponent(grpCode)}`), + menus: () => api.get('/api/common/menus'), +}; + +// ── 알림 ── +export interface NotificationDto { + id: string; + recipientId: string; + eventId: string | null; + notiType: string; + title: string; + message: string | null; + link: string | null; + readAt: string | null; + createdAt: string; +} + +export const notificationApi = { + list: (unreadOnly = false, page = 0, size = 20) => + api.get>( + `/api/work/notifications?unreadOnly=${unreadOnly}&page=${page}&size=${size}`, + ), + unreadCount: () => api.get<{ unread: number }>('/api/work/notifications/unread-count'), + read: (id: string) => api.post(`/api/work/notifications/${encodeURIComponent(id)}/read`), + readAll: () => api.post('/api/work/notifications/read-all'), +}; diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index 69607cd..010a8a2 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -31,6 +31,9 @@ export type ApiErrorCode = | 'RENDER_QUOTA_EXCEEDED' | 'NOT_REGISTERED_COMPANY' | 'NOT_IMPLEMENTED' + | 'ACCOUNT_LOCKED' + | 'OTP_REQUIRED' + | 'OTP_INVALID' | 'INTERNAL'; // ── 0-5 역할 ── diff --git a/src/frontend/src/components/common/NotificationBell.tsx b/src/frontend/src/components/common/NotificationBell.tsx new file mode 100644 index 0000000..22155be --- /dev/null +++ b/src/frontend/src/components/common/NotificationBell.tsx @@ -0,0 +1,75 @@ +/* + * 알림 벨 — 미확인 개수 배지 + 목록 드롭다운. 공통 notification 채널(§7-6) 소비. + */ +import { useCallback, useEffect, useState } from 'react'; +import { notificationApi, type NotificationDto } from '@/api/common'; + +export function NotificationBell(): JSX.Element { + const [unread, setUnread] = useState(0); + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + + const refreshCount = useCallback(async (): Promise => { + try { + const r = await notificationApi.unreadCount(); + setUnread(r.unread); + } catch { + /* 무음 실패 — 벨 배지는 비필수 */ + } + }, []); + + useEffect(() => { + void refreshCount(); + const t = setInterval(() => void refreshCount(), 60000); + return () => clearInterval(t); + }, [refreshCount]); + + const toggle = async (): Promise => { + const next = !open; + setOpen(next); + if (next) { + try { + const page = await notificationApi.list(false, 0, 20); + setItems(page.items); + } catch { + setItems([]); + } + } + }; + + const markAll = async (): Promise => { + await notificationApi.readAll(); + setUnread(0); + setItems((prev) => prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() }))); + }; + + return ( +

+ + {open && ( +
+
+ 알림 + +
+ {items.length === 0 ? ( +

새 알림이 없습니다.

+ ) : ( +
    + {items.map((n) => ( +
  • + {n.title} + {n.message && {n.message}} +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/frontend/src/components/common/OtpChallengeForm.tsx b/src/frontend/src/components/common/OtpChallengeForm.tsx new file mode 100644 index 0000000..aa3f036 --- /dev/null +++ b/src/frontend/src/components/common/OtpChallengeForm.tsx @@ -0,0 +1,55 @@ +/* + * 로그인 2단계 — challengeToken + OTP 코드 검증. secureLogin 이 OTP_REQUIRED 를 반환하면 노출한다. + * 성공 시 최종 LoginResponse 를 상위에 전달(토큰 세팅은 상위 authStore 책임). + */ +import { useState } from 'react'; +import { twoFactorApi } from '@/api/common'; +import type { LoginResponse } from '@/api/types'; + +interface Props { + challengeToken: string; + onVerified: (login: LoginResponse) => void; +} + +export function OtpChallengeForm({ challengeToken, onVerified }: Props): JSX.Element { + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (): Promise => { + setError(null); + setBusy(true); + try { + const login = await twoFactorApi.verifyOtp(challengeToken, code.trim()); + onVerified(login); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
{ + e.preventDefault(); + void submit(); + }} + > + + setCode(e.target.value)} + autoFocus + /> + + {error &&

{error}

} +
+ ); +} diff --git a/src/frontend/src/components/common/OtpSetupCard.tsx b/src/frontend/src/components/common/OtpSetupCard.tsx new file mode 100644 index 0000000..d6e45f5 --- /dev/null +++ b/src/frontend/src/components/common/OtpSetupCard.tsx @@ -0,0 +1,73 @@ +/* + * 2차 인증(OTP) 등록 카드 — 마이페이지용. setup(QR 발급) → Authenticator 앱 스캔 → confirm(코드 검증) 활성화. + * 시크릿은 화면 1회 표기(백엔드 응답 노출 금지 원칙과 별개로, 사용자 본인 등록 목적). + */ +import { useState } from 'react'; +import { twoFactorApi, type OtpSetupResponse } from '@/api/common'; + +export function OtpSetupCard(): JSX.Element { + const [setup, setSetup] = useState(null); + const [code, setCode] = useState(''); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const start = async (): Promise => { + setError(null); + setBusy(true); + try { + setSetup(await twoFactorApi.setup()); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + const confirm = async (): Promise => { + setError(null); + setBusy(true); + try { + await twoFactorApi.confirm(code.trim()); + setDone(true); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+

2차 인증(OTP) 등록

+ {done ? ( +

OTP가 활성화되었습니다. 다음 로그인부터 인증 코드가 필요합니다.

+ ) : ( + <> + {!setup ? ( + + ) : ( +
+

Authenticator 앱으로 아래 QR을 스캔하세요.

+ OTP QR + + setCode(e.target.value)} + /> + +
+ )} + + )} + {error &&

{error}

} +
+ ); +} diff --git a/src/frontend/src/hooks/useCommonCodes.ts b/src/frontend/src/hooks/useCommonCodes.ts new file mode 100644 index 0000000..6b6cd71 --- /dev/null +++ b/src/frontend/src/hooks/useCommonCodes.ts @@ -0,0 +1,37 @@ +/* + * 공통코드 조회 훅 — 코드그룹의 코드 목록을 로드(드롭다운 등 공용). Phase B §5B 공통 레이어. + */ +import { useEffect, useState } from 'react'; +import { commonApi, type CodeDto } from '@/api/common'; + +export function useCommonCodes(grpCode: string): { + codes: CodeDto[]; + loading: boolean; + error: string | null; +} { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + commonApi + .codes(grpCode) + .then((rows) => { + if (alive) setCodes(rows); + }) + .catch((e: Error) => { + if (alive) setError(e.message); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [grpCode]); + + return { codes, loading, error }; +}