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:
parent
72dd9f1cf3
commit
89d59d3093
@ -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(로컬 보존).
|
||||||
|
|
||||||
|
|||||||
119
docs/design.md
119
docs/design.md
@ -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/·타 문서 무수정 |
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 "";
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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만 반환.
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.security.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** OTP 등록 확인 — Authenticator 앱 코드로 활성화. */
|
||||||
|
public record OtpConfirmRequest(@NotBlank String code) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.security.dto;
|
||||||
|
|
||||||
|
/** 마이페이지 OTP 상태(활성 여부·검증 방식). 시크릿 미포함. */
|
||||||
|
public record OtpStatusResponse(boolean otpEnabled, String verifyMethod) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.system.role;
|
||||||
|
|
||||||
|
/** 권한 정의. */
|
||||||
|
public record PermissionDto(String permCode, String permName, String description) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.system.sysuser;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** 관리자 비밀번호 초기화 — 새 비밀번호는 env/입력값. 응답·로그 노출 금지. */
|
||||||
|
public record PasswordResetRequest(@NotBlank String newPassword) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 회의 상세 — 본문 + 액션아이템. */
|
||||||
|
public record MeetingDetail(MeetingDto meeting, List<MeetingActionDto> actions) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.work.meeting;
|
||||||
|
|
||||||
|
/** 회의록 저장 요청. */
|
||||||
|
public record MinutesRequest(String minutes) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.work.opinion;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** 의견 답변 요청. */
|
||||||
|
public record OpinionCommentRequest(@NotBlank String content) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.work.opinion;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 의견 상세 — 본문 + 답변 목록. */
|
||||||
|
public record OpinionDetail(OpinionDto opinion, List<OpinionCommentDto> comments) {
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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 >= CAST(#{fromAt} AS timestamptz)</if>
|
||||||
|
<if test="toAt != null and toAt != ''">AND start_at <= 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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.zioinfo.kintex.work.stats;
|
||||||
|
|
||||||
|
/** 통계 집계 항목(코드/라벨 → 건수·시간). */
|
||||||
|
public record StatItem(String name, long count, double hours) {
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 >= CAST(#{fromDate} AS date)</if>
|
||||||
|
<if test="toDate != null and toDate != ''">AND work_date <= 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);
|
||||||
|
}
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user