feat(phase-b): WISE 공통/시스템관리 레이어 + 2FA/OTP + MDI 셸 표준

공통레이어(PLANNING §5B, WISE/UIWS 이식):
- 인증강화 2FA: TotpService(RFC6238)·OTP 등록/검증/초기화·로그인 실패 잠금·admin 비번 env 주입(admin123 시드 없음). 기존 JWT/RBAC 무수정, security 패키지 레이어만 추가
- 시스템관리: 사용자·역할/권한·공통코드·메뉴·시스템설정·감사(@Audited AOP). SystemAccessGuard(전역 ADMIN/홀매니저) — EventAccessGuard와 직교 이중 RBAC
- 공통 업무기능: worklog·schedule·message·notice·opinion·meeting·report·notification(단일 채널)·stats·search
- 스키마 V7(시스템관리·보안)·V8(공통 업무모듈) 멱등, V1~V6 불변

design.md v1.2: MDI 셸 표준 §2.7(탭바·다중문서·상태보존·세션복원·단축키·역할별)
CLAUDE.md: G1·G2 게이트 해소 반영

backend gradlew build·frontend tsc EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 19:23:02 +09:00
parent 72dd9f1cf3
commit 89d59d3093
115 changed files with 5171 additions and 6 deletions

View File

@ -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 **에이전트(전문 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(로컬 보존). **확보 자산:** `docs/assets/floorplans/` — 홀별 평면도 JPG 15장 + CAD(제1전시장 "평면,트렌치.dwg" 포함) → PLANNING R4(트렌치·CAD) 해소. 평면도 입력 포맷 = **CAD(DWG) + JPG**. CAD zip은 gitignore(로컬 보존).

View File

@ -1,6 +1,6 @@
# 킨텍스 AI 전시관리 시스템 — UI 디자인 스펙 (design.md) # 킨텍스 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변형) 학습·정합 > 근거 문서: `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)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다. > **단일 출처(Single Source of Truth)**: 프론트 구현 토큰·컴포넌트 계약은 본 문서가 권위다. Stitch 화면(code.html/screen.png)은 시각 참조로만 사용하며, 상충 시 본 문서를 따른다.
> 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어. > 용도: **각 화면의 "Stitch 프롬프트" 코드블록을 Google Stitch(stitch.withgoogle.com)에 그대로 붙여넣어 화면을 생성**한다. 문서는 한국어, Stitch 프롬프트만 영어.
@ -112,10 +112,14 @@
└─ 현장 검수 (모바일, SCR-M2) └─ 현장 검수 (모바일, SCR-M2)
``` ```
### 2-2. 내비게이션 ### 2-2. 내비게이션 (MDI 셸 기준 — v1.2)
- **웹**: 좌측 고정 사이드바(240px) — 상단 행사 전환 드롭다운(행사명+기간+홀), 역할별 메뉴, 하단 사용자 메뉴. 상단 바에는 브레드크럼 + 알림 벨(마감·승인 이벤트) + "D-31" 행사 카운트다운 칩 상시 노출. - **웹(업무 포털)**: **MDI(다중 문서 인터페이스) 셸**을 표준 AppShell로 채택한다. 좌측 고정 사이드바(240px) + **상단 문서 탭바** + 중앙 활성 문서 영역의 3영역 구조다.
- **모바일**: 하단 탭 바 4개 — 홈 / 내 부스(또는 승인 큐) / 알림 / 더보기. 캔버스 화면은 뷰어 모드. - 좌측 사이드바(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 소관 — 여기서는 계약만 정의).
- **`<AppShell>`**: 사이드바 + `<UtilityBar>` + `<TabBar>` + `<DocumentHost>` 조립. 역할·eventId를 props로 받아 메뉴·세션키·허용 라우트를 주입.
- **`<TabBar>`**: 탭 목록 렌더·드래그 재정렬·오버플로우(스크롤+드롭다운)·`+` 팔레트. `role="tablist"`. props: `tabs`, `activeId`, `onActivate`, `onClose`, `onReorder`, `maxTabs=12`.
- **`<Tab>`**: 단일 탭 프레젠테이션(아이콘·라벨·dirty ●·닫기). `role="tab"`. 상태: 활성/비활성/hover/dirty/pinned.
- **`<DocumentHost>`**: 열린 문서들을 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. 화면별 스펙 ## 3. 화면별 스펙
> P0 모듈(M2·M3·M4·M5) 중심 총 14개 화면(웹 12 + 모바일 2). 각 Stitch 프롬프트는 화면 1개를 1회 생성하는 기준으로 작성했다. > 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 | 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.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.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/·타 문서 무수정 |

View File

@ -48,6 +48,12 @@ dependencies {
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${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 --- // --- Lombok ---
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'

View File

@ -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}에서 취득. <b>인자 ·응답 본문은 기록하지 않는다</b>(민감정보 차단).
*/
@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;
}
}
}

View File

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

View File

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

View File

@ -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<String, Object> p);
@Select("""
<script>
SELECT id, actor_id AS actorId, actor_name AS actorName, action, target_type AS targetType,
target_id AS targetId, event_id AS eventId, summary, result,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt
FROM audit_log
<where>
<if test="action != null and action != ''">AND action = #{action}</if>
<if test="actorId != null and actorId != ''">AND actor_id = #{actorId}</if>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
</where>
ORDER BY id DESC
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("action") String action, @Param("actorId") String actorId,
@Param("eventId") String eventId,
@Param("size") int size, @Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM audit_log
<where>
<if test="action != null and action != ''">AND action = #{action}</if>
<if test="actorId != null and actorId != ''">AND actor_id = #{actorId}</if>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
</where>
</script>
""")
long count(@Param("action") String action, @Param("actorId") String actorId,
@Param("eventId") String eventId);
}

View File

@ -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<String, Object> 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<AuditLogDto> 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<Map<String, Object>> 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<String, Object> 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);
}
}

View File

@ -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} 기록한다. <b>민감정보·비번·스택트레이스는 기록하지 않는다.</b>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
/** 감사 액션 코드(예: LAYOUT_APPROVE, SYS_USER_CREATE). */
String action();
/** 대상 유형(테이블/도메인, 예: app_user, layout). */
String targetType() default "";
}

View File

@ -14,6 +14,9 @@ public enum ErrorCode {
RENDER_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "행사 이미지 생성 쿼터를 초과했습니다."), RENDER_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "행사 이미지 생성 쿼터를 초과했습니다."),
NOT_REGISTERED_COMPANY(HttpStatus.FORBIDDEN, "킨텍스 등록업체만 참여할 수 있습니다."), NOT_REGISTERED_COMPANY(HttpStatus.FORBIDDEN, "킨텍스 등록업체만 참여할 수 있습니다."),
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."), NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."),
ACCOUNT_LOCKED(HttpStatus.LOCKED, "로그인 시도가 일시적으로 제한되었습니다."),
OTP_REQUIRED(HttpStatus.UNAUTHORIZED, "2차 인증이 필요합니다."),
OTP_INVALID(HttpStatus.UNAUTHORIZED, "OTP 코드가 올바르지 않습니다."),
INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다."); INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "요청 처리 중 오류가 발생했습니다.");
private final HttpStatus status; private final HttpStatus status;

View File

@ -33,7 +33,8 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/api/auth/login", "/ws/**", .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()) .anyRequest().authenticated())
.exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> { .exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> {
// 스택트레이스 미노출 표준 봉투로 401만 반환. // 스택트레이스 미노출 표준 봉투로 401만 반환.

View File

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

View File

@ -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;
/**
* 최초 관리자 프로비저닝 <b>비밀번호는 env 주입만</b>(ADMIN_PASSWORD). 하드코딩·admin123 시드 절대 금지(계약 §5B-3).
* <p>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);
}
}

View File

@ -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<String, Object> 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<String, Object> 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;
}
}
}

View File

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

View File

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

View File

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

View File

@ -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 유지하고 레이어를 추가 제공한다.
* <ul>
* <li>POST /api/auth/login/secure 1단계(잠금+자격검증). OTP 사용자면 challengeToken 반환.</li>
* <li>POST /api/auth/otp/verify 2단계(OTP 코드) 최종 토큰.</li>
* <li>setup/confirm/status/reset 로그인 사용자의 OTP 등록/해제(본인).</li>
* </ul>
*/
@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<SecureLoginResponse> secureLogin(@Valid @RequestBody SecureLoginRequest req) {
return ApiResponse.ok(service.secureLogin(req.email(), req.password()));
}
/** 공개 — 2단계 OTP 검증 → 최종 토큰. */
@PostMapping("/otp/verify")
public ApiResponse<LoginResponse> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(service.verifyOtp(req.challengeToken(), req.code()));
}
/** 인증 — OTP 등록 시작(시크릿·QR 발급). */
@PostMapping("/otp/setup")
public ApiResponse<OtpSetupResponse> 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<Void> 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<OtpStatusResponse> status(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(service.otpStatus(principal.userId()));
}
/** 인증 — 본인 OTP 해제. */
@PostMapping("/otp/reset")
public ApiResponse<Void> resetSelf(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
service.resetOtp(principal.userId());
return ApiResponse.ok(null);
}
}

View File

@ -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).
* <p>기존 {@code AuthServiceImpl}/{@code JwtService} <b>교체하지 않고</b> 레이어만 얹는다
* 자격검증·토큰발급은 기존 (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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) {
for (Map<String, Object> 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<WorkspaceDto> workspaces = new ArrayList<>();
if (roleRows != null) {
for (Map<String, Object> 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; }
}
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.security.dto;
import jakarta.validation.constraints.NotBlank;
/** OTP 등록 확인 — Authenticator 앱 코드로 활성화. */
public record OtpConfirmRequest(@NotBlank String code) {
}

View File

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

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.security.dto;
/** 마이페이지 OTP 상태(활성 여부·검증 방식). 시크릿 미포함. */
public record OtpStatusResponse(boolean otpEnabled, String verifyMethod) {
}

View File

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

View File

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

View File

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

View File

@ -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 플랫폼 역할).
* <p>홀매니저(킨텍스 내부 운영/관리자) 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;
}
}

View File

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

View File

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

View File

@ -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<List<CodeGroupDto>> groups(@AuthenticationPrincipal KintexPrincipal principal) {
authGuard.require(principal);
return ApiResponse.ok(service.groups());
}
@GetMapping("/api/common/codes/{grpCode}")
public ApiResponse<List<CodeDto>> 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<Void> 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<Void> 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<Void> 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<Void> deleteCode(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String grpCode, @PathVariable String code) {
adminGuard.requireAdmin(principal);
service.deleteCode(grpCode, code);
return ApiResponse.ok(null);
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> p);
@Delete("DELETE FROM common_code WHERE grp_code = #{grpCode} AND code = #{code}")
int deleteCode(@Param("grpCode") String grpCode, @Param("code") String code);
}

View File

@ -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<CodeGroupDto> 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<CodeDto> 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<String, Object> 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<String, Object> 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; }
}
}

View File

@ -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<List<MenuDto>> 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<List<MenuDto>> 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<Void> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
adminGuard.requireAdmin(principal);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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<Map<String, Object>> 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<String, Object> p);
@Delete("DELETE FROM sys_menu WHERE id = #{id}")
int delete(@Param("id") String id);
}

View File

@ -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<MenuDto> tree(String userRole, boolean isAdmin) {
List<Map<String, Object>> rows = mapper.findAll();
int rank = rank(isAdmin ? "ADMIN" : userRole);
// 1) 노드 생성(가시성 필터: use_yn=Y 이고 required_role 이하)
Map<String, MenuDto> byId = new HashMap<>();
List<Map<String, Object>> visible = new ArrayList<>();
for (Map<String, Object> 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<String, Object> r : visible) {
byId.put(str(r.get("id")), toNode(r, new ArrayList<>()));
}
// 2) 부모-자식 연결
List<MenuDto> roots = new ArrayList<>();
for (Map<String, Object> 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<MenuDto> flat() {
return mapper.findAll().stream().map(r -> toNode(r, List.of())).toList();
}
public void save(MenuDto m) {
Map<String, Object> 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<String, Object> r, List<MenuDto> 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<MANAGER<ADMIN. */
private static int rank(String role) {
if (role == null) return 0;
return switch (role) {
case "ADMIN" -> 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; }
}
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.system.role;
/** 권한 정의. */
public record PermissionDto(String permCode, String permName, String description) {
}

View File

@ -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<List<RoleDto>> roles(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.roles());
}
@GetMapping("/permissions")
public ApiResponse<List<PermissionDto>> permissions(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.permissions());
}
@Audited(action = "ROLE_SAVE", targetType = "sys_role")
@PostMapping
public ApiResponse<Void> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String roleCode) {
guard.requireAdmin(principal);
service.delete(roleCode);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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<Map<String, Object>> findRoles();
@Select("SELECT perm_code AS permCode, perm_name AS permName, description "
+ "FROM sys_permission ORDER BY perm_code")
List<Map<String, Object>> findPermissions();
@Select("SELECT perm_code FROM sys_role_permission WHERE role_code = #{roleCode} ORDER BY perm_code")
List<String> 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<String, Object> 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);
}

View File

@ -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<RoleDto> 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<PermissionDto> 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<String, Object> 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); }
}

View File

@ -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<SettingDto>> list(@AuthenticationPrincipal KintexPrincipal principal) {
guard.requireAdmin(principal);
return ApiResponse.ok(service.list());
}
@GetMapping("/{key}")
public ApiResponse<SettingDto> 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<Void> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String key) {
guard.requireAdmin(principal);
service.delete(key);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> p);
@Delete("DELETE FROM sys_setting WHERE setting_key = #{key}")
int delete(@Param("key") String key);
}

View File

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

View File

@ -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<SettingDto> list() {
return mapper.findAll().stream().map(SettingService::toMaskedDto).toList();
}
public SettingDto get(String key) {
Map<String, Object> r = mapper.findByKey(key);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
return toMaskedDto(r);
}
public void save(SettingSaveRequest req, String actorId) {
Map<String, Object> 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<String, Object> 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); }
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.system.sysuser;
import jakarta.validation.constraints.NotBlank;
/** 관리자 비밀번호 초기화 — 새 비밀번호는 env/입력값. 응답·로그 노출 금지. */
public record PasswordResetRequest(@NotBlank String newPassword) {
}

View File

@ -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<PageResponse<SysUserDto>> 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<SysUserDto> 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<SysUserDto> 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<SysUserDto> 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<Void> 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<Void> 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<Void> resetOtp(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String userId) {
guard.requireAdmin(principal);
service.resetOtp(userId);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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("""
<script>
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,
to_char(last_login_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS lastLoginAt
FROM app_user
<where>
<if test="keyword != null and keyword != ''">
AND (email ILIKE '%'||#{keyword}||'%' OR display_name ILIKE '%'||#{keyword}||'%')
</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="roleCode != null and roleCode != ''">AND role_code = #{roleCode}</if>
</where>
ORDER BY created_at DESC
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("keyword") String keyword, @Param("status") String status,
@Param("roleCode") String roleCode,
@Param("size") int size, @Param("offset") int offset);
@Select("""
<script>
SELECT count(*) FROM app_user
<where>
<if test="keyword != null and keyword != ''">
AND (email ILIKE '%'||#{keyword}||'%' OR display_name ILIKE '%'||#{keyword}||'%')
</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="roleCode != null and roleCode != ''">AND role_code = #{roleCode}</if>
</where>
</script>
""")
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<String, Object> 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<String, Object> 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<String, Object> 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);
}

View File

@ -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<SysUserDto> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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));
}
}

View File

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

View File

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

View File

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

View File

@ -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<PageResponse<MeetingDto>> 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<MeetingDetail> get(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MeetingDto> create(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody MeetingSaveRequest req) {
guard.require(principal);
return ApiResponse.ok(service.create(principal, req));
}
@PutMapping("/{id}")
public ApiResponse<MeetingDto> 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<Void> 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<MeetingActionDto> 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<Void> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.work.meeting;
import java.util.List;
/** 회의 상세 — 본문 + 액션아이템. */
public record MeetingDetail(MeetingDto meeting, List<MeetingActionDto> actions) {
}

View File

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

View File

@ -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("""
<script>
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>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
</where>
ORDER BY meeting_at DESC LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(Map<String, Object> q);
@Select("""
<script>
SELECT count(*) FROM meeting
<where><if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if></where>
</script>
""")
long count(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> p);
@Update("UPDATE meeting_action SET status=#{status} WHERE id=#{id}")
int updateActionStatus(@Param("id") String id, @Param("status") String status);
}

View File

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

View File

@ -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<MeetingDto> list(String eventId, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
Map<String, Object> q = new HashMap<>();
q.put("eventId", eventId);
q.put("size", s);
q.put("offset", p * s);
List<Map<String, Object>> 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<String, Object> r = mapper.findById(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
List<MeetingActionDto> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> base(MeetingSaveRequest req) {
Map<String, Object> 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<String, Object> 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<String, Object> 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; }
}
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.work.meeting;
/** 회의록 저장 요청. */
public record MinutesRequest(String minutes) {
}

View File

@ -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<PageResponse<MessageDto>> 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<PageResponse<MessageDto>> 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<Map<String, Long>> unread(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(Map.of("unread", service.unreadCount(principal)));
}
@PostMapping
public ApiResponse<Map<String, String>> 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<Void> read(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.markRead(principal, id);
return ApiResponse.ok(null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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<String, Object> p);
@Insert("INSERT INTO message_recipient (id, message_id, recipient_id, recv_type) "
+ "VALUES (#{id}, #{messageId}, #{recipientId}, #{recvType})")
int insertRecipient(Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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);
}

View File

@ -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<String> recipientIds,
List<String> refIds) {
}

View File

@ -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<String, Object> 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<String> ids, String recvType) {
if (ids == null) {
return;
}
for (String rid : ids) {
if (rid == null || rid.isBlank()) {
continue;
}
Map<String, Object> 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<MessageDto> inbox(KintexPrincipal principal, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
List<Map<String, Object>> 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<MessageDto> sent(KintexPrincipal principal, int page, int size) {
int p = Math.max(page, 0);
int s = size <= 0 ? 20 : Math.min(size, 200);
List<Map<String, Object>> 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<String, Object> 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); }
}

View File

@ -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<PageResponse<NoticeDto>> 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<NoticeDto> 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<NoticeDto> 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<NoticeDto> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
mgrGuard.requireManager(principal);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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("""
<script>
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>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="category != null and category != ''">AND category = #{category}</if>
<if test="keyword != null and keyword != ''">AND title ILIKE '%'||#{keyword}||'%'</if>
</where>
ORDER BY pinned DESC, created_at DESC LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(Map<String, Object> q);
@Select("""
<script>
SELECT count(*) FROM notice
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="category != null and category != ''">AND category = #{category}</if>
<if test="keyword != null and keyword != ''">AND title ILIKE '%'||#{keyword}||'%'</if>
</where>
</script>
""")
long count(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> p);
@Delete("DELETE FROM notice WHERE id = #{id}")
int delete(@Param("id") String id);
}

View File

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

View File

@ -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<NoticeDto> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> p = base(req);
p.put("id", id);
p.put("authorId", principal.userId());
p.put("authorName", principal.displayName());
mapper.insert(p);
Map<String, Object> r = mapper.findById(id);
return toDto(r);
}
@Transactional
public NoticeDto update(String id, NoticeSaveRequest req) {
Map<String, Object> 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<String, Object> base(NoticeSaveRequest req) {
Map<String, Object> 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<String, Object> 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; }
}
}

View File

@ -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<PageResponse<NotificationDto>> 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<Map<String, Long>> unread(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(Map.of("unread", service.unreadCount(principal)));
}
@PostMapping("/{id}/read")
public ApiResponse<Void> read(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.markRead(principal, id);
return ApiResponse.ok(null);
}
@PostMapping("/read-all")
public ApiResponse<Void> readAll(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
service.markAllRead(principal);
return ApiResponse.ok(null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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("""
<script>
SELECT id, recipient_id AS recipientId, event_id AS eventId, noti_type AS notiType,
title, message, link,
to_char(read_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS readAt,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt
FROM notification
WHERE recipient_id = #{userId}
<if test="unreadOnly">AND read_at IS NULL</if>
ORDER BY created_at DESC LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> listForUser(Map<String, Object> 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<String, Object> 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);
}

View File

@ -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<String, Object> 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<NotificationDto> 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<String, Object> q = new HashMap<>();
q.put("userId", principal.userId());
q.put("unreadOnly", unreadOnly);
q.put("size", s);
q.put("offset", pg * s);
List<Map<String, Object>> 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<String, Object> 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); }
}

View File

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

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.work.opinion;
import jakarta.validation.constraints.NotBlank;
/** 의견 답변 요청. */
public record OpinionCommentRequest(@NotBlank String content) {
}

View File

@ -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<PageResponse<OpinionDto>> 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<OpinionDetail> get(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
authGuard.require(principal);
return ApiResponse.ok(service.get(principal, id));
}
@PostMapping
public ApiResponse<OpinionDto> 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<Void> 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<OpinionCommentDto> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
authGuard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,7 @@
package com.zioinfo.kintex.work.opinion;
import java.util.List;
/** 의견 상세 — 본문 + 답변 목록. */
public record OpinionDetail(OpinionDto opinion, List<OpinionCommentDto> comments) {
}

View File

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

View File

@ -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("""
<script>
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>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
</where>
ORDER BY created_at DESC LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(Map<String, Object> q);
@Select("""
<script>
SELECT count(*) FROM opinion
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
</where>
</script>
""")
long count(Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> p);
}

View File

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

View File

@ -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<OpinionDto> 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<String, Object> q = new HashMap<>();
q.put("eventId", eventId);
q.put("status", status);
q.put("size", s);
q.put("offset", p * s);
List<Map<String, Object>> 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<String, Object> 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<OpinionCommentDto> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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); }
}

View File

@ -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<PageResponse<ReportDto>> 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<ReportDto> get(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.get(principal, id));
}
@PostMapping
public ApiResponse<ReportDto> create(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody ReportSaveRequest req) {
guard.require(principal);
return ApiResponse.ok(service.create(principal, req));
}
@PutMapping("/{id}")
public ApiResponse<ReportDto> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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("""
<script>
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>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="reportType != null and reportType != ''">AND report_type = #{reportType}</if>
<if test="mineOnly">AND author_id = #{userId}</if>
</where>
ORDER BY created_at DESC LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(Map<String, Object> q);
@Select("""
<script>
SELECT count(*) FROM report
<where>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="reportType != null and reportType != ''">AND report_type = #{reportType}</if>
<if test="mineOnly">AND author_id = #{userId}</if>
</where>
</script>
""")
long count(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> p);
@Delete("DELETE FROM report WHERE id=#{id} AND author_id=#{authorId}")
int delete(@Param("id") String id, @Param("authorId") String authorId);
}

View File

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

View File

@ -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<ReportDto> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> base(ReportSaveRequest req) {
Map<String, Object> 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<String, Object> 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); }
}

View File

@ -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<ScheduleDto>> 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<ScheduleDto> get(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.get(principal, id));
}
@PostMapping
public ApiResponse<ScheduleDto> create(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody ScheduleSaveRequest req) {
guard.require(principal);
return ApiResponse.ok(service.create(principal, req));
}
@PutMapping("/{id}")
public ApiResponse<ScheduleDto> 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<Void> delete(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
service.delete(principal, id);
return ApiResponse.ok(null);
}
}

View File

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

View File

@ -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("""
<script>
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>
<if test="mineOnly">AND owner_id = #{userId}</if>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="fromAt != null and fromAt != ''">AND start_at &gt;= CAST(#{fromAt} AS timestamptz)</if>
<if test="toAt != null and toAt != ''">AND start_at &lt;= CAST(#{toAt} AS timestamptz)</if>
</where>
ORDER BY start_at
</script>
""")
List<Map<String, Object>> search(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> p);
@Delete("DELETE FROM schedule WHERE id=#{id} AND owner_id=#{ownerId}")
int delete(@Param("id") String id, @Param("ownerId") String ownerId);
}

View File

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

View File

@ -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<ScheduleDto> list(KintexPrincipal principal, String eventId, String fromAt, String toAt) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> base(ScheduleSaveRequest req) {
Map<String, Object> 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<String, Object> 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));
}
}

View File

@ -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<List<SearchResultItem>> search(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam("q") String q,
@RequestParam(defaultValue = "30") int limit) {
guard.require(principal);
return ApiResponse.ok(service.search(principal, q, limit));
}
}

View File

@ -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("""
<script>
SELECT * FROM (
SELECT 'WORKLOG' AS type, id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS createdAt, created_at AS ord
FROM worklog
WHERE title ILIKE '%'||#{q}||'%' <if test="seeAll == false">AND writer_id = #{userId}</if>
UNION ALL
SELECT 'NOTICE', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM notice WHERE title ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'MEETING', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM meeting WHERE title ILIKE '%'||#{q}||'%'
UNION ALL
SELECT 'REPORT', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM report WHERE title ILIKE '%'||#{q}||'%' <if test="seeAll == false">AND author_id = #{userId}</if>
UNION ALL
SELECT 'OPINION', id, title,
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"'), created_at
FROM opinion
WHERE title ILIKE '%'||#{q}||'%'
AND (secret_yn = 'N' <if test="seeAll == false">OR author_id = #{userId}</if>)
) s ORDER BY ord DESC LIMIT #{size}
</script>
""")
List<Map<String, Object>> searchAll(@Param("q") String q, @Param("userId") String userId,
@Param("seeAll") boolean seeAll, @Param("size") int size);
}

View File

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

View File

@ -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<SearchResultItem> 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<Map<String, Object>> 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); }
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.kintex.work.stats;
/** 통계 집계 항목(코드/라벨 → 건수·시간). */
public record StatItem(String name, long count, double hours) {
}

View File

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

View File

@ -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 = """
<if test="mineOnly">AND writer_id = #{userId}</if>
<if test="eventId != null and eventId != ''">AND event_id = #{eventId}</if>
<if test="fromDate != null and fromDate != ''">AND work_date &gt;= CAST(#{fromDate} AS date)</if>
<if test="toDate != null and toDate != ''">AND work_date &lt;= CAST(#{toDate} AS date)</if>
""";
@Select("<script>SELECT status AS name, count(*) AS count, COALESCE(SUM(hours),0) AS hours "
+ "FROM worklog <where>1=1 " + FILTER + "</where> GROUP BY status ORDER BY status</script>")
List<Map<String, Object>> byStatus(Map<String, Object> q);
@Select("<script>SELECT work_type AS name, count(*) AS count, COALESCE(SUM(hours),0) AS hours "
+ "FROM worklog <where>1=1 " + FILTER + "</where> GROUP BY work_type ORDER BY work_type</script>")
List<Map<String, Object>> byType(Map<String, Object> q);
@Select("<script>SELECT writer_name AS name, count(*) AS count, COALESCE(SUM(hours),0) AS hours "
+ "FROM worklog <where>1=1 " + FILTER + "</where> GROUP BY writer_id, writer_name "
+ "ORDER BY count DESC LIMIT 50</script>")
List<Map<String, Object>> byWriter(Map<String, Object> q);
@Select("<script>SELECT count(*) AS count, COALESCE(SUM(hours),0) AS hours "
+ "FROM worklog <where>1=1 " + FILTER + "</where></script>")
Map<String, Object> summary(Map<String, Object> q);
}

View File

@ -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<String, Object> 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<String, Object> 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<StatItem> toItems(List<Map<String, Object>> 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; }
}
}

Some files were not shown because too many files have changed in this diff Show More