release: 2026-03-11 (13건 커밋) #82

병합
dnlee develop 에서 main 로 14 commits 를 머지했습니다 2026-03-11 12:55:33 +09:00
68개의 변경된 파일5390개의 추가작업 그리고 1942개의 파일을 삭제

파일 보기

@ -1,47 +0,0 @@
---
name: explorer
description: 코드베이스 탐색 및 분석 에이전트. 3개 이상의 파일을 탐색하거나 프로젝트 구조를 파악할 때 사용한다.
model: sonnet
tools: Read, Glob, Grep
maxTurns: 12
---
지정된 영역 내에서 코드베이스를 분석하고 구조화된 결과를 반환한다.
읽기 전용 — 파일을 수정하지 않는다.
## 자율 범위
- 메인 세션이 지정한 **탐색 영역**(디렉토리 또는 파일 목록) 내에서 자유롭게 탐색
- 영역 내 파일 간 의존성 추적, 임포트 체인 분석은 자율 수행
- 탐색 영역 밖 파일은 임포트/참조 관계 확인 목적으로만 열람 가능
## 입력 (메인 세션이 제공)
- **탐색 영역**: 디렉토리 경로 또는 파일 목록
- **목적**: 분석 목적이나 답변할 질문 (구체적일수록 좋음)
## 출력 형식
```
## 분석 결과
### 구조
- 핵심 파일/디렉토리 구성 (파일:라인 근거)
### 발견사항
- 목적에 대한 답변 (파일:라인 근거 포함)
### 패턴
- 코드 컨벤션, 반복 패턴, 아키텍처 특성
### 확신도
- 각 발견사항별: 확정 / 추정(근거) / 판단불가(필요 정보)
### 범위 외 참고
- 탐색 영역 밖에서 발견된 관련 사항 (해당 시)
```
## 제약
- 파일 수정/생성 금지
- 정보 부족 시 추측하지 않고 "판단불가 — [필요한 정보]"로 표시

파일 보기

@ -1,55 +0,0 @@
---
name: implementer
description: 모듈 단위 코드 구현 에이전트. 독립 모듈 구현이나 병렬 작업이 필요할 때 사용한다.
model: sonnet
tools: Read, Write, Edit, Glob, Grep, Bash
maxTurns: 20
---
메인 세션이 정의한 계약(인터페이스, 타입, 제약)에 따라 코드를 구현한다.
내부 구현 방식은 자율 판단하되, 계약과 제약을 벗어나지 않는다.
## 자율 범위
- 계약(함수 시그니처, API 스펙, 타입)은 메인 세션이 확정 — 변경 불가
- 내부 구현 로직, 헬퍼 함수, 에러 처리 방식은 자율 판단
- **[참조]** 파일이 제공되면 해당 파일의 코드 패턴(네이밍, 구조, 에러 처리)을 따름
## 입력 (메인 세션이 제공)
- **[파일]**: 수정/생성할 파일 경로
- **[계약]**: 인터페이스, 타입, 함수 시그니처, API 스펙 등 외부 계약
- **[참조]**: 패턴을 따를 기존 파일 (선택, 제공 시 해당 패턴 준수)
- **[제약]**: 특별한 요구사항 (선택)
## 출력 형식
```
## 구현 결과
### 수정 파일
- 파일 경로 목록
### 파일별 변경
- 각 파일에서 추가/수정한 내용 요약
### 자체 검증
- tsc --noEmit: 통과 / 실패(에러 내용)
- [추가 검증 항목]: 결과
### 계약 외 판단
- 자율 판단한 구현 결정 사항 (메인 세션 참고용)
### 보고 사항 (해당 시)
- 계약 불충분: 추가 정보가 필요한 항목
- 아키텍처 영향: 범위 밖 변경이 필요한 사항
```
## 제약
- [파일]에 명시되지 않은 파일 수정 금지
- [계약]의 시그니처/타입 임의 변경 금지
- 아키텍처 변경이 필요하면 구현하지 않고 "보고 사항"에 기록
- 커밋/푸시 금지
- any 타입 금지, strict 모드 준수
- 구현 완료 후 tsc --noEmit 자체 검증 수행

파일 보기

@ -1,79 +0,0 @@
---
name: reviewer
description: 코드 리뷰 및 품질 검증 에이전트. 커밋 전 검증이나 MR 리뷰 시 사용한다.
model: sonnet
tools: Read, Glob, Grep, Bash
maxTurns: 12
---
변경된 코드를 체크리스트 기반으로 검증한다.
읽기 전용 — 파일을 수정하지 않는다.
## 자율 범위
- 지정된 변경 파일을 자유롭게 분석
- 관련 파일(임포트 대상, 호출자)도 열람하여 영향 범위 확인 가능
- 기본 체크리스트 + 자체 판단으로 추가 이슈 탐지
## 입력 (메인 세션이 제공)
- **[대상]**: 리뷰할 파일 경로 목록 또는 git diff 범위
- **[체크리스트]**: 검증 항목 (선택, 미제공 시 기본 체크리스트 사용)
## 기본 체크리스트
1. 타입 안전성 — any, 타입 단언(as), non-null 단언(!) 사용
2. 에러 처리 — try-catch 누락, empty catch, 에러 무시
3. 보안 — 하드코딩 인증정보, injection 가능성, XSS
4. 미사용 코드 — 미사용 import, 변수, 함수
5. 팀 정책 — team-policy.md 위반 사항
6. 일관성 — 기존 코드 패턴과의 불일치
7. import/export 정합성 — default import와 named export 불일치 검증 (아래 상세 절차 참고)
## import/export 정합성 검증 절차
tsc는 `esModuleInterop` 덕분에 통과하지만 Vite/Rollup 프로덕션 빌드에서 실패하는 케이스를 잡는다.
**검증 대상**: 변경된 파일 중 `import ... from` 구문이 포함된 모든 파일
**검증 방법**:
1. 변경 파일에서 `import <식별자> from '<경로>'` (중괄호 없는 default import) 패턴을 추출한다
2. 해당 `<경로>` 모듈을 열어 `export default`가 실제로 존재하는지 확인한다
3. 모듈이 `export const <식별자>` (named export)만 제공하면 **FAIL**`import { <식별자> } from` 으로 수정 필요
**판정 기준**:
- `export default` 없는 모듈을 default import → **FAIL (Critical)** — 빌드 실패 유발
- `export default` 있는 모듈을 default import → PASS
- `import { ... } from` (named import) → 검증 불필요
**실전 예시** (프로젝트에서 발생했던 사례):
```typescript
// ❌ FAIL — api.ts에는 export default 없음
import api from '@common/services/api';
// ✅ PASS — named import
import { api } from '@common/services/api';
```
## 출력 형식
```
## 리뷰 결과
| # | 항목 | 판정 | 근거 |
|---|------|------|------|
| 1 | [항목명] | PASS / FAIL | [파일:라인] 설명 |
### 추가 발견 (자체 판단)
- [파일:라인] 설명 (심각도: Critical / Warning / Info)
### 요약
- 전체: N개 PASS / M개 FAIL
- 커밋 가능 여부: 가능 / 차단 권고(사유)
```
## 제약
- 파일 수정/생성 금지
- 각 항목에 반드시 PASS 또는 FAIL 판정 (애매하면 FAIL + 사유)
- 스타일 개선 제안은 "추가 발견"에 Info로만 기록

파일 보기

@ -1,69 +0,0 @@
# TypeScript/React 코드 스타일 규칙
## TypeScript 일반
- strict 모드 필수 (`tsconfig.json`)
- `any` 사용 금지 (불가피한 경우 주석으로 사유 명시)
- 타입 정의: `interface` 우선 (type은 유니온/인터섹션에만)
- 들여쓰기: 2 spaces
- 세미콜론: 사용
- 따옴표: single quote
- trailing comma: 사용
## React 규칙
### 컴포넌트
- 함수형 컴포넌트 + hooks 패턴만 사용
- 클래스 컴포넌트 사용 금지
- 컴포넌트 파일 당 하나의 export default 컴포넌트
- Props 타입은 interface로 정의 (ComponentNameProps)
```tsx
interface UserCardProps {
name: string;
email: string;
onEdit?: () => void;
}
const UserCard = ({ name, email, onEdit }: UserCardProps) => {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
{onEdit && <button onClick={onEdit}>편집</button>}
</div>
);
};
export default UserCard;
```
### Hooks
- 커스텀 훅은 `use` 접두사 (예: `useAuth`, `useFetch`)
- 훅은 `src/hooks/` 디렉토리에 분리
- 복잡한 상태 로직은 커스텀 훅으로 추출
### 상태 관리
- 컴포넌트 로컬 상태: `useState`
- 공유 상태: Context API 또는 Zustand
- 서버 상태: React Query (TanStack Query) 권장
### 이벤트 핸들러
- `handle` 접두사: `handleClick`, `handleSubmit`
- Props로 전달 시 `on` 접두사: `onClick`, `onSubmit`
## 스타일링
- CSS Modules 또는 Tailwind CSS (프로젝트 설정에 따름)
- 인라인 스타일 지양
- !important 사용 금지
## API 호출
- API 호출 로직은 `src/services/`에 분리
- Axios 또는 fetch wrapper 사용
- 에러 처리: try-catch + 사용자 친화적 에러 메시지
- 환경별 API URL은 `.env`에서 관리
## 기타
- console.log 커밋 금지 (디버깅 후 제거)
- 매직 넘버/문자열 → 상수 파일로 추출
- 사용하지 않는 import, 변수 제거 (ESLint로 검증)
- 이미지/아이콘은 `src/assets/`에 관리

파일 보기

@ -1,84 +0,0 @@
# Git 워크플로우 규칙
## 브랜치 전략
### 브랜치 구조
```
main ← 배포 가능한 안정 브랜치 (보호됨)
└── develop ← 개발 통합 브랜치
├── feature/ISSUE-123-기능설명
├── bugfix/ISSUE-456-버그설명
└── hotfix/ISSUE-789-긴급수정
```
### 브랜치 네이밍
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
### 브랜치 규칙
- main, develop 브랜치에 직접 커밋/푸시 금지
- feature 브랜치는 develop에서 분기
- hotfix 브랜치는 main에서 분기
- 머지는 반드시 MR(Merge Request)을 통해 수행
## 커밋 메시지 규칙
### Conventional Commits 형식
```
type(scope): subject
body (선택)
footer (선택)
```
### type (필수)
| type | 설명 |
|------|------|
| feat | 새로운 기능 추가 |
| fix | 버그 수정 |
| docs | 문서 변경 |
| style | 코드 포맷팅 (기능 변경 없음) |
| refactor | 리팩토링 (기능 변경 없음) |
| test | 테스트 추가/수정 |
| chore | 빌드, 설정 변경 |
| ci | CI/CD 설정 변경 |
| perf | 성능 개선 |
### scope (선택)
- 변경 범위를 나타내는 짧은 단어
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
### subject (필수)
- 변경 내용을 간결하게 설명
- 한국어, 영어 모두 허용
- 72자 이내
- 마침표(.) 없이 끝냄
### 예시
```
feat(auth): JWT 기반 로그인 구현
fix(배치): 야간 배치 타임아웃 수정
docs: README에 빌드 방법 추가
refactor(user-service): 중복 로직 추출
test(결제): 환불 로직 단위 테스트 추가
chore: Gradle 의존성 버전 업데이트
```
## MR(Merge Request) 규칙
### MR 생성
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
### MR 리뷰
- 최소 1명의 리뷰어 승인 필수
- CI 검증 통과 필수 (설정된 경우)
- 리뷰 코멘트 모두 해결 후 머지
### MR 머지
- Squash Merge 권장 (깔끔한 히스토리)
- 머지 후 소스 브랜치 삭제

파일 보기

@ -1,53 +0,0 @@
# TypeScript/React 네이밍 규칙
## 파일명
| 항목 | 규칙 | 예시 |
|------|------|------|
| 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` |
| 페이지 | PascalCase | `Dashboard.tsx`, `UserList.tsx` |
| 훅 | camelCase + use 접두사 | `useAuth.ts`, `useFetch.ts` |
| 서비스 | camelCase | `userService.ts`, `authApi.ts` |
| 유틸리티 | camelCase | `formatDate.ts`, `validation.ts` |
| 타입 정의 | camelCase | `user.types.ts`, `api.types.ts` |
| 상수 | camelCase | `routes.ts`, `constants.ts` |
| 스타일 | 컴포넌트명 + .module | `UserCard.module.css` |
| 테스트 | 대상 + .test | `UserCard.test.tsx` |
## 변수/함수
| 항목 | 규칙 | 예시 |
|------|------|------|
| 변수 | camelCase | `userName`, `isLoading` |
| 함수 | camelCase | `getUserList`, `formatDate` |
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY`, `API_BASE_URL` |
| boolean 변수 | is/has/can/should 접두사 | `isActive`, `hasPermission` |
| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` |
| 이벤트 Props | on 접두사 | `onClick`, `onSubmit` |
## 타입/인터페이스
| 항목 | 규칙 | 예시 |
|------|------|------|
| interface | PascalCase | `UserProfile`, `ApiResponse` |
| Props | 컴포넌트명 + Props | `UserCardProps`, `ButtonProps` |
| 응답 타입 | 도메인 + Response | `UserResponse`, `LoginResponse` |
| 요청 타입 | 동작 + Request | `CreateUserRequest` |
| Enum | PascalCase | `UserStatus`, `HttpMethod` |
| Enum 값 | UPPER_SNAKE_CASE | `ACTIVE`, `PENDING` |
| Generic | 단일 대문자 | `T`, `K`, `V` |
## 디렉토리
- 모두 kebab-case 또는 camelCase (프로젝트 통일)
- 예: `src/components/common/`, `src/hooks/`, `src/services/`
## 컴포넌트 구조 예시
```
src/components/user-card/
├── UserCard.tsx # 컴포넌트
├── UserCard.module.css # 스타일
├── UserCard.test.tsx # 테스트
└── index.ts # re-export
```

파일 보기

@ -1,61 +0,0 @@
# 서브에이전트 활용 정책
커스텀 에이전트(`.claude/agents/`)를 활용하여 컨텍스트를 보호하고 병렬 작업을 수행한다.
메인 세션은 리더 역할(설계, 조율, 최종 판단)에 집중하고, 실제 작업은 서브에이전트에 위임한다.
## 에이전트 구성
| 에이전트 | 역할 | 자율성 | 모델 |
|----------|------|--------|------|
| explorer | 코드베이스 탐색/분석 (읽기 전용) | 높음 | sonnet |
| implementer | 모듈 단위 코드 구현 | 중간 | sonnet |
| reviewer | 코드 리뷰/품질 검증 (읽기 전용) | 높음 | sonnet |
## 사용 시점
### explorer
- 3개 이상의 파일/디렉토리를 탐색해야 할 때
- 프로젝트 구조나 패턴을 파악할 때
- 의존성 체인, 임포트 관계를 추적할 때
### implementer
- 독립 모듈/컴포넌트를 구현할 때
- 여러 모듈을 병렬로 구현할 때 (각각 별도 implementer)
- 반복 패턴을 여러 파일에 적용할 때
### reviewer
- 구현 완료 후 커밋 전 검증
- MR 생성 전 자체 리뷰
- 변경 범위가 클 때 (5개 이상 파일)
## 사용하지 않는 경우
- 단일 파일의 간단한 수정
- 위치를 이미 아는 코드 수정
- 설정 파일 변경
## 메인 세션 작업 흐름
### 단일 모듈
1. 메인: 계약(인터페이스, 타입) 설계
2. implementer: 계약 기반 구현 + 자체 검증
3. reviewer: 변경 파일 리뷰
4. 메인: 결과 확인 → 커밋
### 다중 모듈 (병렬)
1. 메인: 모듈 간 공유 인터페이스 확정
2. implementer A + B: 각 모듈 동시 구현
3. 메인: 통합 확인 (인터페이스 일치)
4. reviewer: 전체 변경 리뷰
5. 메인: 최종 확인 → 커밋
### 분석
1. explorer: 탐색 영역 + 목적 전달 → 분석 결과 반환
2. 메인: "추정" 항목만 직접 확인 → 판단
## 핵심 원칙
- **읽기 전용 에이전트(explorer/reviewer)**: 결과가 부정확해도 손해 없음 → 높은 자율성 부여
- **쓰기 에이전트(implementer)**: 계약은 고정, 내부 구현은 자율 → 중간 자율성
- **같은 파일을 두 에이전트가 동시에 수정하지 않는다**
- **커밋/푸시는 반드시 메인 세션에서 수행**

파일 보기

@ -1,34 +0,0 @@
# 팀 정책 (Team Policy)
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
## 보안 정책
### 금지 행위
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
### 인증 정보 관리
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
- 설정 파일은 `.gitignore`에 반드시 포함
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
## 코드 품질 정책
### 필수 검증
- 커밋 전 빌드(컴파일) 성공 확인
- 린트 경고 0개 유지 (CI에서도 검증)
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
### 코드 리뷰
- main 브랜치 머지 시 최소 1명 리뷰 필수
- 리뷰어 승인 없이 머지 불가
## 문서화 정책
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
- README.md에 프로젝트 빌드/실행 방법 유지

파일 보기

@ -1,64 +0,0 @@
# TypeScript/React 테스트 규칙
## 테스트 프레임워크
- Vitest (Vite 프로젝트) 또는 Jest
- React Testing Library (컴포넌트 테스트)
- MSW (Mock Service Worker, API 모킹)
## 테스트 구조
### 단위 테스트
- 유틸리티 함수, 커스텀 훅 테스트
- 외부 의존성 없이 순수 로직 검증
```typescript
describe('formatDate', () => {
it('날짜를 YYYY-MM-DD 형식으로 변환한다', () => {
const result = formatDate(new Date('2026-02-14'));
expect(result).toBe('2026-02-14');
});
it('유효하지 않은 날짜는 빈 문자열을 반환한다', () => {
const result = formatDate(new Date('invalid'));
expect(result).toBe('');
});
});
```
### 컴포넌트 테스트
- React Testing Library 사용
- 사용자 관점에서 테스트 (구현 세부사항이 아닌 동작 테스트)
- `getByRole`, `getByText` 등 접근성 기반 쿼리 우선
```tsx
describe('UserCard', () => {
it('사용자 이름과 이메일을 표시한다', () => {
render(<UserCard name="홍길동" email="hong@test.com" />);
expect(screen.getByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('hong@test.com')).toBeInTheDocument();
});
it('편집 버튼 클릭 시 onEdit 콜백을 호출한다', async () => {
const onEdit = vi.fn();
render(<UserCard name="홍길동" email="hong@test.com" onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: '편집' }));
expect(onEdit).toHaveBeenCalledOnce();
});
});
```
### 테스트 패턴
- **Arrange-Act-Assert** 구조
- 테스트 설명은 한국어로 작성 (`it('사용자 이름을 표시한다')`)
- 하나의 테스트에 하나의 검증
## 테스트 커버리지
- 새로 작성하는 유틸리티 함수: 테스트 필수
- 컴포넌트: 주요 상호작용 테스트 권장
- API 호출: MSW로 모킹하여 에러/성공 시나리오 테스트
## 금지 사항
- 구현 세부사항 테스트 금지 (state 값 직접 확인 등)
- `getByTestId` 남용 금지 (접근성 쿼리 우선)
- 스냅샷 테스트 남용 금지 (변경에 취약)
- `setTimeout`으로 비동기 대기 금지 → `waitFor`, `findBy` 사용

파일 보기

@ -1,14 +0,0 @@
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
if echo "$COMMAND" | grep -qE 'git commit'; then
cat <<RESP
{
"hookSpecificOutput": {
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
}
}
RESP
else
echo '{}'
fi

파일 보기

@ -1,23 +0,0 @@
#!/bin/bash
INPUT=$(cat)
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
if [ -z "$CWD" ]; then
CWD=$(pwd)
fi
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
CONTEXT=""
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
fi
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
fi
if [ -n "$CONTEXT" ]; then
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
else
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
fi

파일 보기

@ -1,8 +0,0 @@
#!/bin/bash
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
INPUT=$(cat)
cat <<RESP
{
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
}
RESP

파일 보기

@ -1,5 +1,8 @@
{ {
"$schema": "https://json.schemastore.org/claude-code-settings.json", "$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
},
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run *)", "Bash(npm run *)",
@ -81,4 +84,4 @@
} }
] ]
} }
} }

파일 보기

@ -1,30 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(npx tailwindcss init:*)",
"Bash(/Users/nankyunglee/Documents/wing/LayerList_New.csv:*)",
"Bash(/Users/nankyunglee/Documents/wing/backend/src/utils/layerIcons.ts:*)",
"Bash(/Users/nankyunglee/Documents/wing/transform_layers.js:*)",
"Bash(__NEW_LINE_5781bf96384c4503__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
"Bash(__NEW_LINE_2dfc1a230be907fa__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
"Bash(npm run db:seed:*)",
"Bash(npm run dev:*)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(npm start)",
"Bash(npm run build:*)",
"Bash(curl:*)",
"WebFetch(domain:www.khoa.go.kr)",
"WebFetch(domain:opendrift.github.io)",
"WebFetch(domain:www.windy.com)",
"Bash(npx tsc:*)",
"Bash(brew install:*)",
"Bash(python3:*)",
"Bash(npx vite build:*)",
"Bash(pdftotext:*)",
"Bash(wc:*)",
"Bash(ls:*)"
]
}
}

파일 보기

@ -1,65 +0,0 @@
---
name: create-mr
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
allowed-tools: "Bash, Read, Grep"
argument-hint: "[target-branch: develop|main] (기본: develop)"
---
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
- 현재 브랜치가 main/develop이 아닌지 확인
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
### 2. 변경 내역 분석
```bash
git log develop..HEAD --oneline
git diff develop..HEAD --stat
```
- 커밋 목록과 변경된 파일 목록 수집
- 주요 변경 사항 요약 작성
### 3. MR 정보 구성
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- **본문**:
```markdown
## 변경 사항
- (커밋 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 4. Gitea API로 MR 생성
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# Gitea API 호출
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 5. 결과 출력
- MR URL 출력
- 리뷰어 지정 안내
- 다음 단계: 리뷰 대기 → 승인 → 머지
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)

파일 보기

@ -1,49 +0,0 @@
---
name: fix-issue
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "<issue-number>"
---
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
## 수행 단계
### 1. 이슈 조회
```bash
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
-H "Authorization: token ${GITEA_TOKEN}"
```
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
- 이슈 내용을 사용자에게 요약하여 보여줌
### 2. 브랜치 생성
이슈 라벨에 따라 브랜치 타입 결정:
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
- 그 외 → `feature/ISSUE-번호-설명`
- 긴급 → `hotfix/ISSUE-번호-설명`
```bash
git checkout develop
git pull origin develop
git checkout -b {type}/ISSUE-{number}-{slug}
```
### 3. 이슈 분석
이슈 내용을 바탕으로:
- 관련 파일 탐색 (Grep, Glob 활용)
- 영향 범위 파악
- 수정 방향 제안
### 4. 수정 계획 제시
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
- 수정할 파일 목록
- 변경 내용 요약
- 예상 영향
### 5. 작업 완료 후
- 변경 사항 요약
- `/create-mr` 실행 안내
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -1,7 +1,6 @@
--- ---
name: init-project name: init-project
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다 description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]" argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
--- ---
@ -46,73 +45,95 @@ curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
### 3. .claude/ 디렉토리 구성 ### 3. .claude/ 디렉토리 구성
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드: 이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조) - `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
### 4. Hook 스크립트 생성 ⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다.
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
- `.claude/scripts/on-pre-compact.sh`: ### 3.5. Gitea 토큰 설정
**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json``env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요.
**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰.
```bash ```bash
#!/bin/bash # 현재 GITEA_TOKEN 설정 여부 확인
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) if [ -z "$GITEA_TOKEN" ]; then
INPUT=$(cat) echo "GITEA_TOKEN 미설정"
cat <<RESP
{
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
}
RESP
```
- `.claude/scripts/on-post-compact.sh`:
```bash
#!/bin/bash
INPUT=$(cat)
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
if [ -z "$CWD" ]; then
CWD=$(pwd)
fi
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
CONTEXT=""
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
fi
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
fi
if [ -n "$CONTEXT" ]; then
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
else
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
fi fi
``` ```
- `.claude/scripts/on-commit.sh`: **GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시:
**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?"
- 옵션 1: 토큰 생성 안내 보기 (추천)
- 옵션 2: 이미 있음 (토큰 입력)
- 옵션 3: 나중에 하기
**토큰 생성 안내 선택 시**, 다음 내용을 표시:
```
📋 Gitea 토큰 생성 방법:
1. 브라우저에서 접속:
https://gitea.gc-si.dev/user/settings/applications
2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭
3. 입력:
- Token Name: "claude-code" (자유롭게 지정)
- Repository and Organization Access: ✅ All (public, private, and limited)
4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지):
┌─────────────────┬──────────────────┬──────────────────────────────┐
│ 항목 │ 권한 │ 용도 │
├─────────────────┼──────────────────┼──────────────────────────────┤
│ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │
│ organization │ Read │ gc 조직 리포 접근 │
│ repository │ Read and Write │ /push, /mr, /release API 호출 │
│ user │ Read │ API 사용자 인증 확인 │
└─────────────────┴──────────────────┴──────────────────────────────┘
5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요.
```
표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요"
- 옵션 1: 토큰 입력 (Other로 입력)
- 옵션 2: 나중에 하기
**토큰 입력 시**:
1. Gitea API로 유효성 검증:
```bash ```bash
#!/bin/bash curl -sf "https://gitea.gc-si.dev/api/v1/user" \
INPUT=$(cat) -H "Authorization: token <입력된 토큰>"
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") ```
if echo "$COMMAND" | grep -qE 'git commit'; then - 성공: `✅ <login> (<full_name>) 인증 확인` 출력
cat <<RESP - 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨):
```json
{ {
"hookSpecificOutput": { "env": {
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정" "GITEA_TOKEN": "<입력된 토큰>"
} }
} }
RESP
else
echo '{}'
fi
``` ```
기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신.
**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행:
```
⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다.
나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요:
{ "env": { "GITEA_TOKEN": "your-token-here" } }
```
### 4. Hook 스크립트 설정
⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다.
여기서는 `settings.json`에 hooks 섹션만 설정한다.
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합): `.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
```json ```json
@ -166,6 +187,13 @@ git config core.hooksPath .githooks
chmod +x .githooks/* chmod +x .githooks/*
``` ```
**pre-commit 훅 검증**: `.githooks/pre-commit`을 실행하여 빌드 검증이 정상 동작하는지 확인.
에러 발생 시 (예: 모노레포가 아닌 특수 구조, 빌드 명령 불일치 등):
1. 프로젝트에 맞게 `.githooks/pre-commit`을 커스텀 수정
2. `.claude/workflow-version.json``"custom_pre_commit": true` 추가
3. 이후 `/sync-team-workflow` 실행 시 pre-commit은 덮어쓰지 않고 보존됨
(`commit-msg`, `post-checkout`은 항상 팀 표준으로 동기화)
### 6. 프로젝트 타입별 추가 설정 ### 6. 프로젝트 타입별 추가 설정
#### java-maven #### java-maven
@ -193,6 +221,20 @@ chmod +x .githooks/*
*.local *.local
``` ```
**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음):
```
# Team workflow (managed by /sync-team-workflow)
.claude/rules/
.claude/agents/
.claude/skills/push/
.claude/skills/mr/
.claude/skills/create-mr/
.claude/skills/release/
.claude/skills/version/
.claude/skills/fix-issue/
.claude/scripts/
```
### 8. Git exclude 설정 ### 8. Git exclude 설정
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가: `.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
@ -236,7 +278,14 @@ curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/dev
} }
``` ```
### 12. 검증 및 요약 ### 12. 팀 워크플로우 최신화
`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다.
이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다.
(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음)
### 13. 검증 및 요약
- 생성/수정된 파일 목록 출력 - 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인 - `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인 - 빌드 명령 실행 가능 확인

파일 보기

@ -1,123 +0,0 @@
---
name: mr
description: 커밋 + 푸시 + Gitea MR을 한 번에 생성합니다
user-invocable: true
argument-hint: "[target-branch: develop|main] (기본: develop)"
allowed-tools: "Bash, Read, Grep"
---
현재 브랜치의 변경 사항을 커밋+푸시하고, Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
```bash
# 현재 브랜치 확인 (main/develop이면 중단)
BRANCH=$(git branch --show-current)
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
```
- 현재 브랜치가 `main` 또는 `develop`이면: "feature 브랜치에서 실행해주세요" 안내 후 종료
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내)
### 2. 커밋 + 푸시 (변경 사항이 있을 때만)
```bash
git status --short
```
**커밋되지 않은 변경이 있으면**:
- 변경 범위(파일 목록, 추가/수정/삭제) 요약 표시
- Conventional Commits 형식 커밋 메시지 자동 생성
- **사용자 확인** (AskUserQuestion): 커밋 메시지 수락/수정/취소
- 수락 시: `git add -A``git commit``git push`
**변경이 없으면**:
- 이미 커밋된 내용으로 MR 생성 진행
- 리모트에 push되지 않은 커밋이 있으면 `git push`
### 3. MR 대상 브랜치 결정
타겟 브랜치 후보를 분석하여 표시:
```bash
# develop과의 차이
git log develop..HEAD --oneline 2>/dev/null
# main과의 차이
git log main..HEAD --oneline 2>/dev/null
```
**사용자 확인** (AskUserQuestion):
- **질문**: "MR 타겟 브랜치를 선택하세요"
- 옵션 1: develop (추천, N건 커밋 차이)
- 옵션 2: main (N건 커밋 차이)
- 옵션 3: 취소
인자($ARGUMENTS)로 브랜치가 지정되었으면 확인 없이 바로 진행.
### 4. MR 정보 구성
```bash
# 커밋 목록
git log {target}..HEAD --oneline
# 변경 파일 통계
git diff {target}..HEAD --stat
```
- **제목**: 커밋이 1개면 커밋 메시지 사용, 여러 개면 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- `bugfix/fix-timeout``fix: fix-timeout`
- **본문**:
```markdown
## 변경 사항
- (커밋 목록 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출, 없으면 생략)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 5. Gitea API로 MR 생성
```bash
# remote URL에서 Gitea 호스트, owner, repo 파싱
# 예: https://gitea.gc-si.dev/gc/my-project.git → host=gitea.gc-si.dev, owner=gc, repo=my-project
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 6. 결과 출력
```
✅ MR 생성 완료
브랜치: feature/my-branch → develop
MR: https://gitea.gc-si.dev/gc/my-project/pulls/42
커밋: 3건, 파일: 5개 변경
다음 단계: 리뷰어 지정 → 승인 대기 → 머지
```
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰
- 없으면: "Gitea 토큰이 필요합니다. Settings → Applications에서 생성하세요" 안내
## 기존 /create-mr과의 차이
- `/mr`: 커밋+푸시 포함, 빠른 실행 (일상적 사용)
- `/create-mr`: MR 생성만, 세부 옵션 지원 (상세 제어)

파일 보기

@ -1,92 +0,0 @@
---
name: push
description: 변경 사항을 확인하고 커밋 + 푸시합니다
user-invocable: true
argument-hint: "[commit-message] (생략 시 자동 생성)"
allowed-tools: "Bash, Read, Grep"
---
현재 브랜치의 변경 사항을 확인하고, 사용자 승인 후 커밋 + 푸시합니다.
커밋 메시지 인자: $ARGUMENTS (생략 시 변경 내용 기반 자동 생성)
## 수행 단계
### 1. 현재 상태 수집
```bash
# 현재 브랜치
git branch --show-current
# 커밋되지 않은 변경 사항
git status --short
# 변경 통계
git diff --stat
git diff --cached --stat
```
### 2. 변경 범위 표시
사용자에게 다음 정보를 **표 형태**로 요약하여 보여준다:
- 현재 브랜치명
- 변경된 파일 목록 (추가/수정/삭제 구분)
- staged vs unstaged 구분
- 변경 라인 수 요약
변경 사항이 없으면 "커밋할 변경 사항이 없습니다" 출력 후 종료.
### 3. 커밋 메시지 결정
**인자가 있는 경우** ($ARGUMENTS가 비어있지 않으면):
- 전달받은 메시지를 커밋 메시지로 사용
- Conventional Commits 형식인지 검증 (아니면 자동 보정 제안)
**인자가 없는 경우**:
- 변경 내용을 분석하여 Conventional Commits 형식 메시지 자동 생성
- 형식: `type(scope): 한국어 설명`
- type 판단 기준:
- 새 파일 추가 → `feat`
- 기존 파일 수정 → `fix` 또는 `refactor`
- 테스트 파일 → `test`
- 설정/빌드 파일 → `chore`
- 문서 파일 → `docs`
### 4. 사용자 확인
AskUserQuestion으로 다음을 확인:
**질문**: "다음 내용으로 커밋하시겠습니까?"
- 옵션 1: 제안된 메시지로 커밋 (추천)
- 옵션 2: 메시지 수정 (Other 입력)
- 옵션 3: 취소
### 5. 커밋 + 푸시 실행
사용자가 수락하면:
```bash
# 모든 변경 사항 스테이징 (untracked 포함)
# 단, .env, secrets/ 등 민감 파일은 제외
git add -A
# 커밋 (.githooks/commit-msg가 형식 검증)
git commit -m "커밋메시지"
# 푸시 (리모트 트래킹 없으면 -u 추가)
git push origin $(git branch --show-current)
```
**주의사항**:
- `git add` 전에 `.env`, `*.key`, `secrets/` 등 민감 파일이 포함되어 있으면 경고
- pre-commit hook 실패 시 에러 메시지 표시 후 수동 해결 안내
- 리모트에 브랜치가 없으면 `git push -u origin {branch}` 사용
### 6. 결과 출력
```
✅ 푸시 완료
브랜치: feature/my-branch
커밋: abc1234 feat(auth): 로그인 검증 로직 추가
변경: 3 files changed, 45 insertions(+), 12 deletions(-)
```

파일 보기

@ -1,134 +0,0 @@
---
name: release
description: develop에서 main으로 릴리즈 MR을 생성합니다
user-invocable: true
argument-hint: ""
allowed-tools: "Bash, Read, Grep"
---
develop 브랜치와 원격 동기화를 확인하고, develop → main 릴리즈 MR을 생성합니다.
## 수행 단계
### 1. 사전 검증
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# GITEA_TOKEN 확인
echo $GITEA_TOKEN
```
- GITEA_TOKEN 환경변수 확인 (없으면 설정 안내 후 종료)
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
### 2. develop 브랜치 동기화 확인
```bash
# 최신 원격 상태 가져오기
git fetch origin
# 로컬 develop과 origin/develop 비교
LOCAL=$(git rev-parse develop 2>/dev/null)
REMOTE=$(git rev-parse origin/develop 2>/dev/null)
BASE=$(git merge-base develop origin/develop 2>/dev/null)
```
**동기화 상태 판단:**
| 상태 | 조건 | 행동 |
|------|------|------|
| 동일 | LOCAL == REMOTE | 바로 MR 생성 진행 |
| 로컬 뒤처짐 | LOCAL == BASE, LOCAL != REMOTE | "origin/develop에 새 커밋이 있습니다. `git pull origin develop` 후 다시 시도하세요" 안내 |
| 로컬 앞섬 | REMOTE == BASE, LOCAL != REMOTE | "로컬에 push되지 않은 커밋이 있습니다. `git push origin develop` 먼저 실행하시겠습니까?" 확인 |
| 분기됨 | 그 외 | "로컬과 원격 develop이 분기되었습니다. 수동으로 해결해주세요" 경고 후 종료 |
**로컬 앞섬 상태에서 사용자가 push 수락하면:**
```bash
git push origin develop
```
### 3. develop → main 차이 분석
```bash
# main 대비 develop의 새 커밋
git log main..origin/develop --oneline
# 변경 파일 통계
git diff main..origin/develop --stat
# 커밋 수
git rev-list --count main..origin/develop
```
차이가 없으면 "develop과 main이 동일합니다. 릴리즈할 변경이 없습니다" 출력 후 종료.
### 4. MR 정보 구성 + 사용자 확인
**제목 자동 생성:**
```
release: YYYY-MM-DD (N건 커밋)
```
**본문 자동 생성:**
```markdown
## 릴리즈 내용
- (develop→main 커밋 목록, Conventional Commits type별 그룹핑)
### 새 기능 (feat)
- feat(auth): 로그인 검증 로직 추가
- feat(batch): 배치 스케줄러 개선
### 버그 수정 (fix)
- fix(api): 타임아웃 처리 수정
### 기타
- chore: 의존성 업데이트
## 변경 파일
- N files changed, +M insertions, -K deletions
## 테스트
- [ ] develop 브랜치 빌드 성공 확인
- [ ] 주요 기능 동작 확인
```
**사용자 확인** (AskUserQuestion):
- **질문**: "다음 내용으로 릴리즈 MR을 생성하시겠습니까?"
- 옵션 1: 생성 (추천)
- 옵션 2: 제목/본문 수정 (Other 입력)
- 옵션 3: 취소
### 5. Gitea API로 릴리즈 MR 생성
```bash
curl -X POST "https://{host}/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "release: 2026-02-19 (12건 커밋)",
"body": "릴리즈 본문",
"head": "develop",
"base": "main",
"labels": []
}'
```
### 6. 결과 출력
```
✅ 릴리즈 MR 생성 완료
브랜치: develop → main
MR: https://gitea.gc-si.dev/gc/my-project/pulls/50
커밋: 12건, 파일: 28개 변경
다음 단계:
1. 리뷰어 지정 (main 브랜치는 1명 이상 리뷰 필수)
2. 승인 후 머지
3. CI/CD 자동 배포 확인 (설정된 경우)
```
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -1,98 +1,165 @@
--- ---
name: sync-team-workflow name: sync-team-workflow
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다 description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
--- ---
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다. 팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다.
호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음).
## 수행 절차 ## 수행 절차
### 1. 글로벌 버전 조회 ### 1. 사전 조건 확인
Gitea API로 template-common 리포의 workflow-version.json 조회:
`.claude/workflow-version.json` 존재 확인:
- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료
설정 읽기:
```bash ```bash
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev") GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "")
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
``` ```
### 2. 버전 비교 프로젝트 타입이 비어있으면 자동 감지:
로컬 `.claude/workflow-version.json``applied_global_version` 필드와 비교: 1. `pom.xml` → java-maven
- 버전 일치 → "최신 버전입니다" 안내 후 종료 2. `build.gradle` / `build.gradle.kts` → java-gradle
- 버전 불일치 → 미적용 변경 항목 추출하여 표시 3. `package.json` + `tsconfig.json` → react-ts
4. 감지 실패 → 사용자에게 선택 요청
### 3. 프로젝트 타입 감지
자동 감지 순서:
1. `.claude/workflow-version.json``project_type` 필드 확인
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
### Gitea 파일 다운로드 URL 패턴 ### Gitea 파일 다운로드 URL 패턴
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): ⚠️ Gitea raw 파일은 반드시 **web raw URL** 사용:
```bash ```bash
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> # common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> # 타입별 파일: ${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/<파일경로>
# 예시:
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
``` ```
### 4. 파일 다운로드 및 적용 ### 2. 디렉토리 준비
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-1. 규칙 파일 (덮어쓰기) 필요한 디렉토리가 없으면 생성:
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체: ```bash
mkdir -p .claude/rules .claude/agents .claude/scripts
mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr
mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue
mkdir -p .githooks
```
### 3. 서버 파일 다운로드 + 적용
각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장.
다운로드 실패한 파일은 경고 출력 후 건너뜀.
#### 3-1. template-common 파일 (덮어쓰기)
**규칙 파일**:
``` ```
.claude/rules/team-policy.md .claude/rules/team-policy.md
.claude/rules/git-workflow.md .claude/rules/git-workflow.md
.claude/rules/code-style.md (타입별) .claude/rules/release-notes-guide.md
.claude/rules/naming.md (타입별) .claude/rules/subagent-policy.md
.claude/rules/testing.md (타입별)
``` ```
#### 4-2. settings.json (부분 갱신) **에이전트 파일**:
- `deny` 목록: 글로벌 최신으로 교체
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
- SessionStart(compact) → on-post-compact.sh
- PreCompact → on-pre-compact.sh
- PostToolUse(Bash) → on-commit.sh
#### 4-3. 스킬 파일 (덮어쓰기)
``` ```
.claude/agents/explorer.md
.claude/agents/implementer.md
.claude/agents/reviewer.md
```
**스킬 파일 (6종)**:
```
.claude/skills/push/SKILL.md
.claude/skills/mr/SKILL.md
.claude/skills/create-mr/SKILL.md .claude/skills/create-mr/SKILL.md
.claude/skills/release/SKILL.md
.claude/skills/version/SKILL.md
.claude/skills/fix-issue/SKILL.md .claude/skills/fix-issue/SKILL.md
.claude/skills/sync-team-workflow/SKILL.md
.claude/skills/init-project/SKILL.md
``` ```
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한) **Hook 스크립트**:
```bash
chmod +x .githooks/*
```
#### 4-5. Hook 스크립트 갱신
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
``` ```
.claude/scripts/on-pre-compact.sh .claude/scripts/on-pre-compact.sh
.claude/scripts/on-post-compact.sh .claude/scripts/on-post-compact.sh
.claude/scripts/on-commit.sh .claude/scripts/on-commit.sh
``` ```
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
### 5. 로컬 버전 업데이트 **Git Hooks** (commit-msg, post-checkout은 항상 교체):
`.claude/workflow-version.json` 갱신: ```
```json .githooks/commit-msg
{ .githooks/post-checkout
"applied_global_version": "새버전",
"applied_date": "오늘날짜",
"project_type": "감지된타입",
"gitea_url": "https://gitea.gc-si.dev"
}
``` ```
다운로드 예시:
```bash
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md"
```
#### 3-2. template-{type} 파일 (타입별 덮어쓰기)
```
.claude/rules/code-style.md
.claude/rules/naming.md
.claude/rules/testing.md
```
**pre-commit hook**:
`.claude/workflow-version.json``custom_pre_commit` 플래그 확인:
- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
- 플래그 없거나 false → `.githooks/pre-commit` 교체
다운로드 예시:
```bash
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md"
```
#### 3-3. 실행 권한 부여
```bash
chmod +x .githooks/* 2>/dev/null
chmod +x .claude/scripts/*.sh 2>/dev/null
```
### 4. settings.json 부분 머지
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
```bash
SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json")
```
다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신:
- `env`: 서버 최신으로 교체
- `deny` 목록: 서버 최신으로 교체
- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합
- `hooks`: 서버 최신으로 교체
### 5. workflow-version.json 갱신
서버의 최신 `workflow-version.json` 조회:
```bash
SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")
```
`.claude/workflow-version.json` 업데이트:
```json
{
"applied_global_version": "<서버 version>",
"applied_date": "<현재날짜>",
"project_type": "<프로젝트타입>",
"gitea_url": "<GITEA_URL>"
}
```
기존 필드(`custom_pre_commit` 등)는 보존.
### 6. 변경 보고 ### 6. 변경 보고
- `git diff`로 변경 내역 확인
- 업데이트된 파일 목록 출력 - 다운로드/갱신된 파일 목록 출력
- 변경 로그(글로벌 workflow-version.json의 changes) 표시 - 서버 `workflow-version.json``changes` 중 최신 항목 표시
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등) - 결과 형태:
```
✅ 팀 워크플로우 동기화 완료
버전: v1.6.0
갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3)
settings.json: 부분 갱신 (env, deny, hooks)
```
## 필요 환경변수
없음 (Gitea raw URL은 인증 불필요)

파일 보기

@ -1,6 +1,7 @@
{ {
"applied_global_version": "1.5.0", "applied_global_version": "1.6.1",
"applied_date": "2026-03-03", "applied_date": "2026-03-11",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev" "gitea_url": "https://gitea.gc-si.dev",
} "custom_pre_commit": true
}

11
.gitignore vendored
파일 보기

@ -64,6 +64,17 @@ frontend/public/hns-manual/images/
.claude/settings.local.json .claude/settings.local.json
.claude/CLAUDE.local.md .claude/CLAUDE.local.md
# Team workflow (managed by /sync-team-workflow)
.claude/rules/
.claude/agents/
.claude/skills/push/
.claude/skills/mr/
.claude/skills/create-mr/
.claude/skills/release/
.claude/skills/version/
.claude/skills/fix-issue/
.claude/scripts/
# Lock files (keep for reproducible builds) # Lock files (keep for reproducible builds)
!frontend/package-lock.json !frontend/package-lock.json
!backend/package-lock.json !backend/package-lock.json

파일 보기

@ -558,7 +558,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@ -1992,7 +1991,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0", "pg-pool": "^3.12.0",

파일 보기

@ -7,6 +7,8 @@ import {
createSatRequest, createSatRequest,
updateSatRequestStatus, updateSatRequestStatus,
isValidSatStatus, isValidSatStatus,
requestOilInference,
checkInferenceHealth,
} from './aerialService.js'; } from './aerialService.js';
import { isValidNumber } from '../middleware/security.js'; import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
@ -221,4 +223,44 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
} }
}); });
// ============================================================
// OIL INFERENCE 라우트
// ============================================================
// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시)
// base64 이미지 전송을 위해 3MB JSON 파서 적용
router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const { image } = req.body;
if (!image || typeof image !== 'string') {
res.status(400).json({ error: 'image (base64) 필드가 필요합니다' });
return;
}
// base64 크기 제한 (약 2MB 이미지)
if (image.length > 3_000_000) {
res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' });
return;
}
const result = await requestOilInference(image);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('abort') || message.includes('timeout')) {
console.error('[aerial] 추론 서버 타임아웃:', message);
res.status(504).json({ error: '추론 서버 응답 시간 초과' });
return;
}
console.error('[aerial] 오일 감지 오류:', err);
res.status(503).json({ error: '추론 서버 연결 불가' });
}
});
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
const health = await checkInferenceHealth();
res.json(health);
});
export default router; export default router;

파일 보기

@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
[sttsCd, sn] [sttsCd, sn]
); );
} }
// ============================================================
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
thicknessMm: number;
}
export interface OilInferenceResult {
mask: string; // base64 uint8 array (values 0-4)
width: number;
height: number;
regions: OilInferenceRegion[];
}
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
try {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }),
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Inference server responded ${response.status}: ${detail}`);
}
return await response.json() as OilInferenceResult;
} finally {
clearTimeout(timeout);
}
}
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`status ${response.status}`);
return await response.json() as { status: string; device?: string };
} catch {
return { status: 'unavailable' };
}
}

파일 보기

@ -72,10 +72,16 @@ export function requirePermission(resource: string, operation: string = 'READ')
req.resolvedPermissions = userInfo.permissions req.resolvedPermissions = userInfo.permissions
} }
const allowedOps = req.resolvedPermissions[resource] // 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
if (allowedOps && allowedOps.includes(operation)) { let cursor: string | undefined = resource
next() while (cursor) {
return const allowedOps = req.resolvedPermissions[cursor]
if (allowedOps && allowedOps.includes(operation)) {
next()
return
}
const colonIdx = cursor.lastIndexOf(':')
cursor = colonIdx > 0 ? cursor.substring(0, colonIdx) : undefined
} }
res.status(403).json({ error: '접근 권한이 없습니다.' }) res.status(403).json({ error: '접근 권한이 없습니다.' })

파일 보기

@ -112,6 +112,23 @@ export async function login(
return userInfo return userInfo
} }
/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */
async function flatPermissionsFallback(userId: string): Promise<Record<string, string[]>> {
const permsResult = await authPool.query(
`SELECT DISTINCT p.RSRC_CD as rsrc_cd, p.OPER_CD as oper_cd
FROM AUTH_PERM p
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
const perms: Record<string, string[]> = {}
for (const p of permsResult.rows) {
if (!perms[p.rsrc_cd]) perms[p.rsrc_cd] = []
if (!perms[p.rsrc_cd].includes(p.oper_cd)) perms[p.rsrc_cd].push(p.oper_cd)
}
return perms
}
export async function getUserInfo(userId: string): Promise<AuthUserInfo> { export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
const userResult = await authPool.query( const userResult = await authPool.query(
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm, `SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
@ -170,30 +187,15 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
permissions = grantedSetToRecord(granted) permissions = grantedSetToRecord(granted)
} else { } else {
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback // AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
const permsResult = await authPool.query( permissions = await flatPermissionsFallback(userId)
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
FROM AUTH_PERM p
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
permissions = {}
for (const p of permsResult.rows) {
permissions[p.rsrc_cd] = ['READ']
}
} }
} catch { } catch {
// AUTH_PERM_TREE 테이블 미존재 시 fallback // AUTH_PERM_TREE 테이블 미존재 시 fallback
const permsResult = await authPool.query( try {
`SELECT DISTINCT p.RSRC_CD as rsrc_cd permissions = await flatPermissionsFallback(userId)
FROM AUTH_PERM p } catch {
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, permissions = {}
[userId]
)
permissions = {}
for (const p of permsResult.rows) {
permissions[p.rsrc_cd] = ['READ']
} }
} }

파일 보기

@ -2,7 +2,7 @@ import { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js' import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.js' import { AuthError } from '../auth/authService.js'
import { import {
listPosts, getPost, createPost, updatePost, deletePost, listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
listManuals, createManual, updateManual, deleteManual, incrementManualDownload, listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
} from './boardService.js' } from './boardService.js'
@ -209,4 +209,22 @@ router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (
} }
}) })
// POST /api/board/admin-delete — 관리자 전용 게시글 삭제 (소유자 검증 없음)
router.post('/admin-delete', requireAuth, requirePermission('admin', 'READ'), async (req, res) => {
try {
const { sn } = req.body
const postSn = typeof sn === 'number' ? sn : parseInt(sn, 10)
if (isNaN(postSn)) {
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
return
}
await adminDeletePost(postSn)
res.json({ success: true })
} catch (err) {
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
console.error('[board] 관리자 삭제 오류:', err)
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
}
})
export default router export default router

파일 보기

@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
[postSn] [postSn]
) )
} }
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
export async function adminDeletePost(postSn: number): Promise<void> {
const existing = await wingPool.query(
`SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
await wingPool.query(
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
[postSn]
)
}

파일 보기

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
오일 유출 감지 추론 서버 (GPU)
시립대 starsafire ResNet101+DANet 모델 기반
실행: uvicorn oil_inference_server:app --host 0.0.0.0 --port 8090
모델 파일 필요: ./V7_SPECIAL.py, ./epoch_165.pth (같은 디렉토리)
"""
import os
import io
import base64
import logging
from collections import Counter
from typing import Optional
import cv2
import numpy as np
from PIL import Image
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# ── MMSegmentation (지연 임포트 — 서버 시작 시 로드) ─────────────────────────
model = None
DEVICE = os.getenv("INFERENCE_DEVICE", "cuda:0")
CLASSES = ("background", "black", "brown", "rainbow", "silver")
PALETTE = [
[0, 0, 0], # 0: background
[0, 0, 204], # 1: black oil (에멀전)
[180, 180, 180], # 2: brown oil (원유)
[255, 255, 0], # 3: rainbow oil (박막)
[178, 102, 255], # 4: silver oil (극박막)
]
THICKNESS_MM = {
1: 1.0, # black
2: 0.1, # brown
3: 0.0003, # rainbow
4: 0.0001, # silver
}
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("oil-inference")
# ── FastAPI App ──────────────────────────────────────────────────────────────
app = FastAPI(title="Oil Spill Inference Server", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class InferenceRequest(BaseModel):
image: str # base64 encoded JPEG/PNG
class OilRegionResult(BaseModel):
classId: int
className: str
pixelCount: int
percentage: float
thicknessMm: float
class InferenceResponse(BaseModel):
mask: str # base64 encoded uint8 array (values 0-4)
width: int
height: int
regions: list[OilRegionResult]
# ── Model Loading ────────────────────────────────────────────────────────────
def load_model():
"""모델을 로드한다. 서버 시작 시 1회 호출."""
global model
try:
from mmseg.apis import init_segmentor
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "V7_SPECIAL.py")
checkpoint_path = os.path.join(script_dir, "epoch_165.pth")
if not os.path.exists(config_path):
logger.error(f"Config not found: {config_path}")
return False
if not os.path.exists(checkpoint_path):
logger.error(f"Checkpoint not found: {checkpoint_path}")
return False
logger.info(f"Loading model on {DEVICE}...")
model = init_segmentor(config_path, checkpoint_path, device=DEVICE)
model.PALETTE = PALETTE
logger.info("Model loaded successfully")
return True
except Exception as e:
logger.error(f"Model loading failed: {e}")
return False
@app.on_event("startup")
async def startup():
success = load_model()
if not success:
logger.warning("Model not loaded — inference will be unavailable")
# ── Endpoints ────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {
"status": "ok" if model is not None else "model_not_loaded",
"device": DEVICE,
"classes": list(CLASSES),
}
@app.post("/inference", response_model=InferenceResponse)
async def inference(req: InferenceRequest):
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
try:
# 1. Base64 → numpy array
img_bytes = base64.b64decode(req.image)
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img_pil)
# 2. 임시 파일로 저장 (mmseg inference_segmentor는 파일 경로 필요)
import tempfile
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp_path = tmp.name
cv2.imwrite(tmp_path, cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR))
# 3. 추론
from mmseg.apis import inference_segmentor
result = inference_segmentor(model, tmp_path)
seg_map = result[0] # (H, W) uint8, values 0-4
# 임시 파일 삭제
os.unlink(tmp_path)
h, w = seg_map.shape
total_pixels = h * w
# 4. 클래스별 통계
counter = Counter(seg_map.flatten().tolist())
regions = []
for class_id in range(1, 5): # 1-4 (skip background)
count = counter.get(class_id, 0)
if count > 0:
regions.append(OilRegionResult(
classId=class_id,
className=CLASSES[class_id],
pixelCount=count,
percentage=round(count / total_pixels * 100, 2),
thicknessMm=THICKNESS_MM[class_id],
))
# 5. 마스크를 base64로 인코딩
mask_bytes = seg_map.astype(np.uint8).tobytes()
mask_b64 = base64.b64encode(mask_bytes).decode("ascii")
return InferenceResponse(
mask=mask_b64,
width=w,
height=h,
regions=regions,
)
except Exception as e:
logger.error(f"Inference error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8090)

파일 보기

@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
torch>=1.13.0
mmcv-full>=1.7.0
mmsegmentation>=0.30.0
opencv-python-headless>=4.8.0
numpy>=1.24.0
Pillow>=10.0.0

파일 보기

@ -97,9 +97,13 @@ app.use(cors({
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지 // 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
const generalLimiter = rateLimit({ const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분 windowMs: 15 * 60 * 1000, // 15분
max: 200, // IP당 최대 200요청 max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려)
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => {
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
},
message: { message: {
error: '요청 횟수 초과', error: '요청 횟수 초과',
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.' message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'

파일 보기

@ -10,6 +10,7 @@ import {
assignRoles, assignRoles,
approveUser, approveUser,
rejectUser, rejectUser,
listOrgs,
} from './userService.js' } from './userService.js'
const router = Router() const router = Router()
@ -30,6 +31,17 @@ router.get('/', async (req, res) => {
} }
}) })
// GET /api/users/orgs — 조직 목록 (/:id 보다 앞에 등록해야 함)
router.get('/orgs', async (_req, res) => {
try {
const orgs = await listOrgs()
res.json(orgs)
} catch (err) {
console.error('[users] 조직 목록 오류:', err)
res.status(500).json({ error: '조직 목록 조회 중 오류가 발생했습니다.' })
}
})
// GET /api/users/:id // GET /api/users/:id
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {

파일 보기

@ -293,6 +293,32 @@ export async function changePassword(userId: string, newPassword: string): Promi
) )
} }
// ── 조직 목록 조회 ──
interface OrgItem {
orgSn: number
orgNm: string
orgAbbrNm: string | null
orgTpCd: string
upperOrgSn: number | null
}
export async function listOrgs(): Promise<OrgItem[]> {
const { rows } = await authPool.query(
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
FROM AUTH_ORG
WHERE USE_YN = 'Y'
ORDER BY ORG_SN`
)
return rows.map((r: Record<string, unknown>) => ({
orgSn: r.org_sn as number,
orgNm: r.org_nm as string,
orgAbbrNm: r.org_abbr_nm as string | null,
orgTpCd: r.org_tp_cd as string,
upperOrgSn: r.upper_org_sn as number | null,
}))
}
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> { export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId]) await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])

파일 보기

@ -254,10 +254,11 @@ CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
-- 10. 초기 데이터: 역할 -- 10. 초기 데이터: 역할
-- ============================================================ -- ============================================================
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'), ('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'), ('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'), ('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N'); ('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
-- ============================================================ -- ============================================================
@ -279,7 +280,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'), (1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'); (1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용 -- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'), (2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'), (2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
@ -289,38 +290,52 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'), (2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'), (2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'), (2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'), (2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'), (2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
(2, 'admin', 'READ', 'N'); (2, 'admin', 'READ', 'N');
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음 -- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), (3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), (3, 'prediction', 'DELETE', 'Y'),
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), (3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), (3, 'hns', 'DELETE', 'Y'),
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), (3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), (3, 'rescue', 'DELETE', 'Y'),
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), (3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), (3, 'reports', 'DELETE', 'Y'),
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
(3, 'assets', 'READ', 'N'), (3, 'assets', 'READ', 'Y'), (3, 'assets', 'CREATE', 'Y'), (3, 'assets', 'UPDATE', 'Y'), (3, 'assets', 'DELETE', 'Y'),
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), (3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), (3, 'scat', 'DELETE', 'Y'),
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
(3, 'admin', 'READ', 'N'); (3, 'admin', 'READ', 'N');
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용 -- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
(4, 'reports', 'READ', 'N'), (4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
(4, 'assets', 'READ', 'N'), (4, 'assets', 'READ', 'N'),
(4, 'scat', 'READ', 'N'), (4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
(4, 'board', 'READ', 'Y'), (4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
(4, 'weather', 'READ', 'Y'), (4, 'weather', 'READ', 'Y'),
(4, 'admin', 'READ', 'N'); (4, 'admin', 'READ', 'N');
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(5, 'prediction', 'READ', 'Y'),
(5, 'hns', 'READ', 'Y'),
(5, 'rescue', 'READ', 'Y'),
(5, 'reports', 'READ', 'N'),
(5, 'aerial', 'READ', 'Y'),
(5, 'assets', 'READ', 'N'),
(5, 'scat', 'READ', 'N'),
(5, 'incidents', 'READ', 'Y'),
(5, 'board', 'READ', 'Y'),
(5, 'weather', 'READ', 'Y'),
(5, 'admin', 'READ', 'N');
-- ============================================================ -- ============================================================
-- 12. 초기 데이터: 조직 -- 12. 초기 데이터: 조직

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,32 @@
-- ============================================================
-- 020: 본청방제과 역할 추가
-- ============================================================
-- 역할 추가 (이미 존재하면 무시)
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN)
SELECT 'HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'
WHERE NOT EXISTS (SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP');
-- 본청방제과 권한 설정: 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
DO $$
DECLARE
v_role_sn INT;
BEGIN
SELECT ROLE_SN INTO v_role_sn FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP';
-- 기존 권한 초기화 (재실행 안전)
DELETE FROM AUTH_PERM WHERE ROLE_SN = v_role_sn;
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(v_role_sn, 'prediction', 'READ', 'Y'), (v_role_sn, 'prediction', 'CREATE', 'Y'), (v_role_sn, 'prediction', 'UPDATE', 'Y'), (v_role_sn, 'prediction', 'DELETE', 'Y'),
(v_role_sn, 'hns', 'READ', 'Y'), (v_role_sn, 'hns', 'CREATE', 'Y'), (v_role_sn, 'hns', 'UPDATE', 'Y'), (v_role_sn, 'hns', 'DELETE', 'Y'),
(v_role_sn, 'rescue', 'READ', 'Y'), (v_role_sn, 'rescue', 'CREATE', 'Y'), (v_role_sn, 'rescue', 'UPDATE', 'Y'), (v_role_sn, 'rescue', 'DELETE', 'Y'),
(v_role_sn, 'reports', 'READ', 'Y'), (v_role_sn, 'reports', 'CREATE', 'Y'), (v_role_sn, 'reports', 'UPDATE', 'Y'), (v_role_sn, 'reports', 'DELETE', 'Y'),
(v_role_sn, 'aerial', 'READ', 'Y'), (v_role_sn, 'aerial', 'CREATE', 'Y'), (v_role_sn, 'aerial', 'UPDATE', 'Y'), (v_role_sn, 'aerial', 'DELETE', 'Y'),
(v_role_sn, 'assets', 'READ', 'Y'), (v_role_sn, 'assets', 'CREATE', 'Y'), (v_role_sn, 'assets', 'UPDATE', 'Y'), (v_role_sn, 'assets', 'DELETE', 'Y'),
(v_role_sn, 'scat', 'READ', 'Y'), (v_role_sn, 'scat', 'CREATE', 'Y'), (v_role_sn, 'scat', 'UPDATE', 'Y'), (v_role_sn, 'scat', 'DELETE', 'Y'),
(v_role_sn, 'incidents', 'READ', 'Y'), (v_role_sn, 'incidents', 'CREATE', 'Y'), (v_role_sn, 'incidents', 'UPDATE', 'Y'), (v_role_sn, 'incidents', 'DELETE', 'Y'),
(v_role_sn, 'board', 'READ', 'Y'), (v_role_sn, 'board', 'CREATE', 'Y'), (v_role_sn, 'board', 'UPDATE', 'Y'),
(v_role_sn, 'weather', 'READ', 'Y'), (v_role_sn, 'weather', 'CREATE', 'Y'),
(v_role_sn, 'admin', 'READ', 'N');
END $$;

파일 보기

@ -4,7 +4,7 @@
연동할 수 있도록 정리한 문서이다. 연동할 수 있도록 정리한 문서이다.
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것. 공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영) > **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)
--- ---
@ -1312,6 +1312,25 @@ app.use(helmet({
})); }));
``` ```
### Vite 개발 서버 프록시
외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/daily_ocean': {
target: 'https://www.khoa.go.kr',
changeOrigin: true,
},
},
},
```
적용되는 보안 헤더: 적용되는 보안 헤더:
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지) - `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
- `X-Frame-Options: DENY` (클릭재킹 방지) - `X-Frame-Options: DENY` (클릭재킹 방지)

파일 보기

@ -657,6 +657,7 @@ Settings -> Actions -> Secrets -> Add Secret
- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다. - API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다.
- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다. - 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다.
- KHOA 해양 이미지(`/daily_ocean`)는 Vite 프록시 경유: `vite.config.ts``proxy` 설정 확인
**타입 에러:** **타입 에러:**

파일 보기

@ -4,6 +4,29 @@
## [Unreleased] ## [Unreleased]
## [2026-03-11]
### 추가
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
- 기상 정보 기상 레이어 업데이트
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
- 유류오염보장계약 시드 데이터 추가 (1391건)
### 수정
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
### 문서
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
### 기타
- CLAUDE_BOT_TOKEN 갱신
- 팀 워크플로우 v1.6.1 동기화
- 팀 워크플로우 v1.6.0 동기화
- 팀 워크플로우 v1.5.0 동기화
## [2026-03-01] ## [2026-03-01]
### 추가 ### 추가

파일 보기

@ -60,12 +60,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'manual', label: '해경매뉴얼', icon: '📘' } { id: 'manual', label: '해경매뉴얼', icon: '📘' }
], ],
weather: null, weather: null,
admin: [ admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
{ id: 'users', label: '사용자 관리', icon: '👥' },
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
]
} }
// 전역 상태 관리 (간단한 방식) // 전역 상태 관리 (간단한 방식)

파일 보기

@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
await api.put(`/users/${id}/roles`, { roleSns }) await api.put(`/users/${id}/roles`, { roleSns })
} }
// 조직 목록 API
export interface OrgItem {
orgSn: number
orgNm: string
orgAbbrNm: string | null
orgTpCd: string
upperOrgSn: number | null
}
export async function fetchOrgs(): Promise<OrgItem[]> {
const response = await api.get<OrgItem[]>('/users/orgs')
return response.data
}
// 역할/권한 API (ADMIN 전용) // 역할/권한 API (ADMIN 전용)
export interface RoleWithPermissions { export interface RoleWithPermissions {
sn: number sn: number

파일 보기

@ -0,0 +1,14 @@
interface AdminPlaceholderProps {
label: string;
}
/** 미구현 관리자 메뉴 placeholder */
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-4xl opacity-20">🚧</div>
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
<div className="text-[11px] font-korean text-text-3"> .</div>
</div>
);
export default AdminPlaceholder;

파일 보기

@ -0,0 +1,156 @@
import { useState } from 'react';
import { ADMIN_MENU } from './adminMenuConfig';
import type { AdminMenuItem } from './adminMenuConfig';
interface AdminSidebarProps {
activeMenu: string;
onSelect: (id: string) => void;
}
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
const [expanded, setExpanded] = useState<Set<string>>(() => {
// 초기: 첫 번째 섹션 열기
const init = new Set<string>();
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
return init;
});
const toggle = (id: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
const containsActive = (item: AdminMenuItem): boolean => {
if (item.id === activeMenu) return true;
return item.children?.some(c => containsActive(c)) ?? false;
};
const renderLeaf = (item: AdminMenuItem, depth: number) => {
const isActive = item.id === activeMenu;
return (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
color: isActive ? 'var(--cyan)' : 'var(--t2)',
fontWeight: isActive ? 600 : 400,
}}
>
{item.label}
</button>
);
};
const renderGroup = (item: AdminMenuItem, depth: number) => {
const isOpen = expanded.has(item.id);
const hasActiveChild = containsActive(item);
return (
<div key={item.id}>
<button
onClick={() => {
toggle(item.id);
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
if (!isOpen && item.children) {
const firstLeaf = findFirstLeaf(item.children);
if (firstLeaf) onSelect(firstLeaf.id);
}
}}
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
fontWeight: hasActiveChild ? 600 : 400,
}}
>
<span>{item.label}</span>
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
</span>
</button>
{isOpen && item.children && (
<div className="flex flex-col gap-px">
{item.children.map(child => renderItem(child, depth + 1))}
</div>
)}
</div>
);
};
const renderItem = (item: AdminMenuItem, depth: number) => {
if (item.children && item.children.length > 0) {
return renderGroup(item, depth);
}
return renderLeaf(item, depth);
};
return (
<div
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
>
{/* 헤더 */}
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
<span></span>
</div>
</div>
{/* 메뉴 목록 */}
<div className="flex flex-col gap-0.5 p-2">
{ADMIN_MENU.map(section => {
const isOpen = expanded.has(section.id);
const hasActiveChild = containsActive(section);
return (
<div key={section.id} className="mb-0.5">
{/* 섹션 헤더 */}
<button
onClick={() => toggle(section.id)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
style={{
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
}}
>
<span className="text-sm">{section.icon}</span>
<span className="flex-1 text-left">{section.label}</span>
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
</span>
</button>
{/* 하위 메뉴 */}
{isOpen && section.children && (
<div className="flex flex-col gap-px mt-0.5 ml-1">
{section.children.map(child => renderItem(child, 1))}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
/** children 중 첫 번째 leaf 노드를 찾는다 */
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
for (const item of items) {
if (!item.children || item.children.length === 0) return item;
const found = findFirstLeaf(item.children);
if (found) return found;
}
return null;
}
export default AdminSidebar;

파일 보기

@ -1,21 +1,42 @@
import { useSubMenu } from '@common/hooks/useSubMenu' import { useState } from 'react';
import UsersPanel from './UsersPanel' import AdminSidebar from './AdminSidebar';
import PermissionsPanel from './PermissionsPanel' import AdminPlaceholder from './AdminPlaceholder';
import MenusPanel from './MenusPanel' import { findMenuLabel } from './adminMenuConfig';
import SettingsPanel from './SettingsPanel' import UsersPanel from './UsersPanel';
import PermissionsPanel from './PermissionsPanel';
import MenusPanel from './MenusPanel';
import SettingsPanel from './SettingsPanel';
import BoardMgmtPanel from './BoardMgmtPanel';
import VesselSignalPanel from './VesselSignalPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = {
users: () => <UsersPanel />,
permissions: () => <PermissionsPanel />,
menus: () => <MenusPanel />,
settings: () => <SettingsPanel />,
notice: () => <BoardMgmtPanel initialCategory="NOTICE" />,
board: () => <BoardMgmtPanel initialCategory="DATA" />,
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
'collect-vessel-signal': () => <VesselSignalPanel />,
};
// ─── AdminView ────────────────────────────────────────────
export function AdminView() { export function AdminView() {
const { activeSubTab } = useSubMenu('admin') const [activeMenu, setActiveMenu] = useState('users');
const renderContent = () => {
const factory = PANEL_MAP[activeMenu];
if (factory) return factory();
const label = findMenuLabel(activeMenu) ?? activeMenu;
return <AdminPlaceholder label={label} />;
};
return ( return (
<div className="flex flex-1 overflow-hidden bg-bg-0"> <div className="flex flex-1 overflow-hidden bg-bg-0">
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{activeSubTab === 'users' && <UsersPanel />} {renderContent()}
{activeSubTab === 'permissions' && <PermissionsPanel />}
{activeSubTab === 'menus' && <MenusPanel />}
{activeSubTab === 'settings' && <SettingsPanel />}
</div> </div>
</div> </div>
) );
} }

파일 보기

@ -0,0 +1,293 @@
import { useState, useEffect, useCallback } from 'react';
import {
fetchBoardPosts,
adminDeleteBoardPost,
type BoardPostItem,
type BoardListResponse,
} from '@tabs/board/services/boardApi';
// ─── 상수 ──────────────────────────────────────────────────
const PAGE_SIZE = 20;
const CATEGORY_TABS = [
{ code: '', label: '전체' },
{ code: 'NOTICE', label: '공지사항' },
{ code: 'DATA', label: '게시판' },
{ code: 'QNA', label: 'Q&A' },
] as const;
const CATEGORY_LABELS: Record<string, string> = {
NOTICE: '공지사항',
DATA: '게시판',
QNA: 'Q&A',
};
function formatDate(dateStr: string | null) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
// ─── 메인 패널 ─────────────────────────────────────────────
interface BoardMgmtPanelProps {
initialCategory?: string;
}
export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelProps) {
const [activeCategory, setActiveCategory] = useState(initialCategory);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [page, setPage] = useState(1);
const [data, setData] = useState<BoardListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await fetchBoardPosts({
categoryCd: activeCategory || undefined,
search: search || undefined,
page,
size: PAGE_SIZE,
});
setData(result);
setSelected(new Set());
} catch {
console.error('게시글 목록 로드 실패');
} finally {
setLoading(false);
}
}, [activeCategory, search, page]);
useEffect(() => { load(); }, [load]);
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
const items = data?.items ?? [];
const toggleSelect = (sn: number) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(sn)) next.delete(sn);
else next.add(sn);
return next;
});
};
const toggleAll = () => {
if (selected.size === items.length) {
setSelected(new Set());
} else {
setSelected(new Set(items.map(i => i.sn)));
}
};
const handleDelete = async () => {
if (selected.size === 0) return;
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
setDeleting(true);
try {
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
await load();
} catch {
alert('삭제 중 오류가 발생했습니다.');
} finally {
setDeleting(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
const handleCategoryChange = (code: string) => {
setActiveCategory(code);
setPage(1);
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
<h2 className="text-sm font-semibold text-text-1"> </h2>
<span className="text-xs text-text-3">
{data?.totalCount ?? 0}
</span>
</div>
{/* 카테고리 탭 + 검색 */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
<div className="flex gap-1">
{CATEGORY_TABS.map(tab => (
<button
key={tab.code}
onClick={() => handleCategoryChange(tab.code)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
activeCategory === tab.code
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
}`}
>
{tab.label}
</button>
))}
</div>
<form onSubmit={handleSearch} className="flex gap-1 ml-auto">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="제목/작성자 검색"
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
/>
<button
type="submit"
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
>
</button>
</form>
</div>
{/* 액션 바 */}
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
<button
onClick={handleDelete}
disabled={selected.size === 0 || deleting}
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
</button>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-bg-1 z-10">
<tr className="border-b border-border-1 text-text-3">
<th className="w-8 py-2 text-center">
<input
type="checkbox"
checked={items.length > 0 && selected.size === items.length}
onChange={toggleAll}
className="accent-blue-500"
/>
</th>
<th className="w-12 py-2 text-center"></th>
<th className="w-20 py-2 text-center"></th>
<th className="py-2 text-left pl-3"></th>
<th className="w-24 py-2 text-center"></th>
<th className="w-16 py-2 text-center"></th>
<th className="w-36 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-text-3"> ...</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-text-3"> .</td>
</tr>
) : (
items.map(post => (
<PostRow
key={post.sn}
post={post}
checked={selected.has(post.sn)}
onToggle={() => toggleSelect(post.sn)}
/>
))
)}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
>
&lt;
</button>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
const p = startPage + i;
if (p > totalPages) return null;
return (
<button
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
}`}
>
{p}
</button>
);
})}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
>
&gt;
</button>
</div>
)}
</div>
);
}
// ─── 행 컴포넌트 ───────────────────────────────────────────
interface PostRowProps {
post: BoardPostItem;
checked: boolean;
onToggle: () => void;
}
function PostRow({ post, checked, onToggle }: PostRowProps) {
return (
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
<td className="py-2 text-center">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="accent-blue-500"
/>
</td>
<td className="py-2 text-center text-text-3">{post.sn}</td>
<td className="py-2 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
'bg-blue-500/15 text-blue-400'
}`}>
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
</span>
</td>
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
{post.pinnedYn === 'Y' && (
<span className="text-[10px] text-orange-400 mr-1">[]</span>
)}
{post.title}
</td>
<td className="py-2 text-center text-text-2">{post.authorName}</td>
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
</tr>
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,204 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
type SignalSource = (typeof SIGNAL_SOURCES)[number];
interface SignalSlot {
time: string; // HH:mm
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
none: 'rgba(255,255,255,0.06)',
};
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function generateTimeSlots(date: string): SignalSlot[] {
const now = new Date();
const isToday = date === now.toISOString().slice(0, 10);
const currentHour = isToday ? now.getHours() : 24;
const currentMin = isToday ? now.getMinutes() : 0;
const slots: SignalSlot[] = [];
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 10) {
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
for (const src of SIGNAL_SOURCES) {
if (!isPast) {
sources[src] = { count: 0, status: 'none' };
} else {
const rand = Math.random();
const count = Math.floor(Math.random() * 200) + 10;
sources[src] = {
count,
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
};
}
}
slots.push({ time, sources });
}
}
return slots;
}
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
if (slots.length === 0) return null;
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
return (
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
{slots.map((slot, i) => {
const s = slot.sources[source];
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
return (
<div
key={i}
className="h-full"
style={{
width: `${100 / 144}%`,
backgroundColor: color,
borderRight: '0.5px solid rgba(0,0,0,0.15)',
}}
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
/>
);
})}
</div>
);
}
// ─── 메인 패널 ─────────────────────────────────────────────
export default function VesselSignalPanel() {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [slots, setSlots] = useState<SignalSlot[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(() => {
setLoading(true);
// TODO: 실제 API 연동 시 fetch 호출로 교체
setTimeout(() => {
setSlots(generateTimeSlots(date));
setLoading(false);
}, 300);
}, [date]);
useEffect(() => {
const timer = setTimeout(() => load(), 0);
return () => clearTimeout(timer);
}, [load]);
// 통계 계산
const stats = SIGNAL_SOURCES.map(src => {
let total = 0, ok = 0, warn = 0, error = 0;
for (const slot of slots) {
const s = slot.sources[src];
if (s.status !== 'none') {
total++;
if (s.status === 'ok') ok++;
else if (s.status === 'warn') warn++;
else error++;
}
}
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
});
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
<h2 className="text-sm font-semibold text-text-1"> </h2>
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
/>
<button
onClick={load}
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
>
</button>
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center h-full">
<span className="text-xs text-text-3"> ...</span>
</div>
) : (
<div className="flex gap-2">
{/* 좌측: 소스 라벨 고정 열 */}
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map(src => {
const c = SOURCE_COLORS[src];
const st = stats.find(s => s.src === src)!;
return (
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
</div>
);
})}
</div>
{/* 우측: 시간축 + 타임라인 바 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 시간 축 (상단) */}
<div className="relative h-5 mb-3">
{HOURS.map(h => (
<span
key={h}
className="absolute text-[10px] text-text-3 font-mono"
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
>
{String(h).padStart(2, '0')}
</span>
))}
<span
className="absolute text-[10px] text-text-3 font-mono"
style={{ right: 0 }}
>
24
</span>
</div>
{/* 소스별 타임라인 바 */}
{SIGNAL_SOURCES.map(src => (
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
<TimelineBar slots={slots} source={src} />
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

파일 보기

@ -1,5 +1,6 @@
export const DEFAULT_ROLE_COLORS: Record<string, string> = { export const DEFAULT_ROLE_COLORS: Record<string, string> = {
ADMIN: 'var(--red)', ADMIN: 'var(--red)',
HQ_CLEANUP: '#34d399',
MANAGER: 'var(--orange)', MANAGER: 'var(--orange)',
USER: 'var(--cyan)', USER: 'var(--cyan)',
VIEWER: 'var(--t3)', VIEWER: 'var(--t3)',

파일 보기

@ -0,0 +1,94 @@
/** 관리자 화면 9-섹션 메뉴 트리 */
export interface AdminMenuItem {
id: string;
label: string;
icon?: string;
children?: AdminMenuItem[];
}
export const ADMIN_MENU: AdminMenuItem[] = [
{
id: 'env-settings', label: '환경설정', icon: '⚙️',
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
],
},
{
id: 'user-info', label: '사용자정보', icon: '👥',
children: [
{ id: 'users', label: '사용자관리' },
{ id: 'permissions', label: '권한관리' },
],
},
{
id: 'board-mgmt', label: '게시판관리', icon: '📋',
children: [
{ id: 'notice', label: '공지사항' },
{ id: 'board', label: '게시판' },
{ id: 'qna', label: 'QNA' },
],
},
{
id: 'reference', label: '기준정보', icon: '🗺️',
children: [
{
id: 'map-mgmt', label: '지도관리',
children: [
{ id: 'map-vector', label: '지도벡데이터' },
{ id: 'map-layer', label: '레이어' },
],
},
{
id: 'sensitive-map', label: '민감자원지도',
children: [
{ id: 'env-ecology', label: '환경/생태' },
{ id: 'social-economy', label: '사회/경제' },
],
},
{
id: 'coast-guard-assets', label: '해경자산',
children: [
{ id: 'cleanup-equip', label: '방제장비' },
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
{ id: 'vessel-materials', label: '방제선 보유자재' },
{ id: 'cleanup-resource', label: '방제자원' },
],
},
],
},
{
id: 'external', label: '연계관리', icon: '🔗',
children: [
{
id: 'collection', label: '수집자료',
children: [
{ id: 'collect-vessel-signal', label: '선박신호' },
{ id: 'collect-hr', label: '인사정보' },
],
},
{
id: 'monitoring', label: '연계모니터링',
children: [
{ id: 'monitor-realtime', label: '실시간 관측자료' },
{ id: 'monitor-forecast', label: '수치예측자료' },
{ id: 'monitor-vessel', label: '선박위치정보' },
{ id: 'monitor-hr', label: '인사' },
],
},
],
},
];
/** 메뉴 ID로 라벨을 찾는 유틸리티 */
export function findMenuLabel(id: string, items: AdminMenuItem[] = ADMIN_MENU): string | null {
for (const item of items) {
if (item.id === id) return item.label;
if (item.children) {
const found = findMenuLabel(id, item.children);
if (found) return found;
}
}
return null;
}

파일 보기

@ -1,6 +1,8 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import Hls from 'hls.js'; import Hls from 'hls.js';
import { detectStreamType } from '../utils/streamUtils'; import { detectStreamType } from '../utils/streamUtils';
import { useOilDetection } from '../hooks/useOilDetection';
import OilDetectionOverlay from './OilDetectionOverlay';
interface CCTVPlayerProps { interface CCTVPlayerProps {
cameraNm: string; cameraNm: string;
@ -9,6 +11,13 @@ interface CCTVPlayerProps {
coordDc?: string | null; coordDc?: string | null;
sourceNm?: string | null; sourceNm?: string | null;
cellIndex?: number; cellIndex?: number;
oilDetectionEnabled?: boolean;
vesselDetectionEnabled?: boolean;
intrusionDetectionEnabled?: boolean;
}
export interface CCTVPlayerHandle {
capture: () => void;
} }
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url'; type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
@ -21,15 +30,19 @@ function toProxyUrl(url: string): string {
return url; return url;
} }
export function CCTVPlayer({ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
cameraNm, cameraNm,
streamUrl, streamUrl,
sttsCd, sttsCd,
coordDc, coordDc,
sourceNm, sourceNm,
cellIndex = 0, cellIndex = 0,
}: CCTVPlayerProps) { oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading'); const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
@ -56,6 +69,73 @@ export function CCTVPlayer({
? 'playing' ? 'playing'
: hlsPlayerState; : hlsPlayerState;
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
videoRef,
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
});
useImperativeHandle(ref, () => ({
capture: () => {
const container = containerRef.current;
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(2, 2);
// 1) video frame
const video = videoRef.current;
if (video && video.readyState >= 2) {
ctx.drawImage(video, 0, 0, w, h);
}
// 2) oil detection overlay
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
if (overlayCanvas) {
ctx.drawImage(overlayCanvas, 0, 0, w, h);
}
// 3) OSD: camera name + timestamp
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(cameraNm, 18, 23);
const ts = new Date().toLocaleString('ko-KR');
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tsW = ctx.measureText(ts).width + 16;
ctx.fillRect(8, h - 26, tsW, 20);
ctx.fillStyle = '#a0aec0';
ctx.fillText(ts, 16, h - 12);
// 4) oil detection info
if (oilResult && oilResult.regions.length > 0) {
const areaText = oilResult.totalAreaM2 >= 1000
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
ctx.font = 'bold 11px sans-serif';
const atW = ctx.measureText(areaText).width + 16;
ctx.fillStyle = 'rgba(239,68,68,0.25)';
ctx.fillRect(8, h - 48, atW, 18);
ctx.fillStyle = '#f87171';
ctx.fillText(areaText, 16, h - 34);
}
// download
const link = document.createElement('a');
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
},
}), [cameraNm, oilResult]);
const destroyHls = useCallback(() => { const destroyHls = useCallback(() => {
if (hlsRef.current) { if (hlsRef.current) {
hlsRef.current.destroy(); hlsRef.current.destroy();
@ -185,7 +265,7 @@ export function CCTVPlayer({
} }
return ( return (
<> <div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */} {/* 로딩 오버레이 */}
{playerState === 'loading' && ( {playerState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
@ -207,13 +287,18 @@ export function CCTVPlayer({
/> />
)} )}
{/* 오일 감지 오버레이 */}
{oilDetectionEnabled && (
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
)}
{/* MJPEG */} {/* MJPEG */}
{streamType === 'mjpeg' && proxiedUrl && ( {streamType === 'mjpeg' && proxiedUrl && (
<img <img
src={proxiedUrl} src={proxiedUrl}
alt={cameraNm} alt={cameraNm}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
onError={() => setPlayerState('error')} onError={() => setHlsPlayerState('error')}
/> />
)} )}
@ -224,10 +309,28 @@ export function CCTVPlayer({
title={cameraNm} title={cameraNm}
className="absolute inset-0 w-full h-full border-none" className="absolute inset-0 w-full h-full border-none"
allow="autoplay; encrypted-media" allow="autoplay; encrypted-media"
onError={() => setPlayerState('error')} onError={() => setHlsPlayerState('error')}
/> />
)} )}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */} {/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20"> <div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white"> <span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
@ -245,6 +348,8 @@ export function CCTVPlayer({
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20"> <div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''} {coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
</div> </div>
</> </div>
); );
} });
CCTVPlayer.displayName = 'CCTVPlayer';

파일 보기

@ -1,7 +1,8 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { fetchCctvCameras } from '../services/aerialApi' import { fetchCctvCameras } from '../services/aerialApi'
import type { CctvCameraItem } from '../services/aerialApi' import type { CctvCameraItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer' import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
/** KHOA HLS 스트림 베이스 URL */ /** KHOA HLS 스트림 베이스 URL */
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
@ -54,6 +55,10 @@ export function CctvView() {
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null) const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
const [gridMode, setGridMode] = useState(1) const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([]) const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@ -226,7 +231,45 @@ export function CctvView() {
>{g.icon}</button> >{g.icon}</button>
))} ))}
</div> </div>
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 </button> <button
onClick={() => setOilDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={oilDetectionEnabled
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--red)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
>
{oilDetectionEnabled ? '🛢 감지 ON' : '🛢 오일 감지'}
</button>
<button
onClick={() => setVesselDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={vesselDetectionEnabled
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '선박 출입 감지'}
>
{vesselDetectionEnabled ? '🚢 감지 ON' : '🚢 선박 출입'}
</button>
<button
onClick={() => setIntrusionDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={intrusionDetectionEnabled
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '침입 감지'}
>
{intrusionDetectionEnabled ? '🚨 감지 ON' : '🚨 침입 감지'}
</button>
<button
onClick={() => {
playerRefs.current.forEach(r => r?.capture())
}}
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
>📷 </button>
</div> </div>
</div> </div>
@ -242,12 +285,16 @@ export function CctvView() {
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}> <div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
{cam ? ( {cam ? (
<CCTVPlayer <CCTVPlayer
ref={el => { playerRefs.current[i] = el }}
cameraNm={cam.cameraNm} cameraNm={cam.cameraNm}
streamUrl={cam.streamUrl} streamUrl={cam.streamUrl}
sttsCd={cam.sttsCd} sttsCd={cam.sttsCd}
coordDc={cam.coordDc} coordDc={cam.coordDc}
sourceNm={cam.sourceNm} sourceNm={cam.sourceNm}
cellIndex={i} cellIndex={i}
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
/> />
) : ( ) : (
<div className="text-[10px] text-text-3 font-korean opacity-40"> </div> <div className="text-[10px] text-text-3 font-korean opacity-40"> </div>

파일 보기

@ -0,0 +1,161 @@
import { useRef, useEffect, memo } from 'react';
import type { OilDetectionResult } from '../utils/oilDetection';
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
export interface OilDetectionOverlayProps {
result: OilDetectionResult | null;
isAnalyzing?: boolean;
error?: string | null;
}
/** 클래스 ID → RGBA 색상 (오버레이용) */
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
1: [0, 0, 204, 90], // black oil → 파란색
2: [180, 180, 180, 90], // brown oil → 회색
3: [255, 255, 0, 90], // rainbow oil → 노란색
4: [178, 102, 255, 90], // silver oil → 보라색
};
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.clientWidth;
const displayH = canvas.clientHeight;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, displayW, displayH);
if (!result || result.regions.length === 0) return;
const { mask, maskWidth, maskHeight } = result;
// 클래스별 색상으로 마스크 렌더링
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
const offCtx = offscreen.getContext('2d');
if (offCtx) {
const imageData = new ImageData(maskWidth, maskHeight);
for (let i = 0; i < mask.length; i++) {
const classId = mask[i];
if (classId === 0) continue; // background skip
const color = CLASS_COLORS[classId];
if (!color) continue;
const pixelIdx = i * 4;
imageData.data[pixelIdx] = color[0];
imageData.data[pixelIdx + 1] = color[1];
imageData.data[pixelIdx + 2] = color[2];
imageData.data[pixelIdx + 3] = color[3];
}
offCtx.putImageData(imageData, 0, 0);
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
}
}, [result]);
const formatArea = (m2: number): string => {
if (m2 >= 1000) {
return `${(m2 / 1_000_000).toFixed(1)} km²`;
}
return `~${Math.round(m2)}`;
};
const hasRegions = result !== null && result.regions.length > 0;
return (
<>
<canvas
ref={canvasRef}
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
/>
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
{/* 에러 표시 */}
{error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
</div>
)}
{/* 클래스별 감지 결과 */}
{hasRegions && result !== null && (
<>
{result.regions.map((region) => {
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
const label = OIL_CLASS_NAMES[region.classId] || region.className;
return (
<div
key={region.classId}
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: `${color}33`,
border: `1px solid ${color}80`,
color,
}}
>
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
</div>
);
})}
{/* 합계 */}
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
</div>
</>
)}
{/* 감지 없음 */}
{!hasRegions && !isAnalyzing && !error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.35)',
color: '#4ade80',
}}
>
</div>
)}
{/* 분석 중 */}
{isAnalyzing && (
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
...
</span>
)}
</div>
</>
);
});
OilDetectionOverlay.displayName = 'OilDetectionOverlay';
export default OilDetectionOverlay;

파일 보기

@ -0,0 +1,84 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection';
import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection';
interface UseOilDetectionOptions {
videoRef: React.RefObject<HTMLVideoElement | null>;
enabled: boolean;
config?: Partial<OilDetectionConfig>;
}
interface UseOilDetectionReturn {
result: OilDetectionResult | null;
isAnalyzing: boolean;
error: string | null;
}
export function useOilDetection(options: UseOilDetectionOptions): UseOilDetectionReturn {
const { videoRef, enabled, config } = options;
const [result, setResult] = useState<OilDetectionResult | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const configRef = useRef<OilDetectionConfig>({
...DEFAULT_OIL_DETECTION_CONFIG,
...config,
});
const isBusyRef = useRef(false);
useEffect(() => {
configRef.current = {
...DEFAULT_OIL_DETECTION_CONFIG,
...config,
};
}, [config]);
const analyze = useCallback(async () => {
if (isBusyRef.current) return; // 이전 호출이 진행 중이면 스킵
const video = videoRef.current;
if (!video || video.readyState < 2) return;
isBusyRef.current = true;
setIsAnalyzing(true);
try {
const detection = await detectOilSpillAPI(video, configRef.current);
setResult(detection);
setError(null);
} catch (err) {
// API 실패 시 이전 결과 유지, 에러 메시지만 갱신
const message = err instanceof Error ? err.message : '추론 서버 연결 불가';
setError(message);
console.warn('[OilDetection] API 호출 실패:', message);
} finally {
isBusyRef.current = false;
setIsAnalyzing(false);
}
}, [videoRef]);
useEffect(() => {
if (!enabled) {
setResult(null);
setIsAnalyzing(false);
setError(null);
isBusyRef.current = false;
return;
}
setIsAnalyzing(true);
// 첫 분석: 2초 후 (영상 로딩 대기)
const firstTimeout = setTimeout(analyze, 2000);
// 반복 분석
const intervalId = setInterval(analyze, configRef.current.captureIntervalMs);
return () => {
clearTimeout(firstTimeout);
clearInterval(intervalId);
};
}, [enabled, analyze]);
return { result, isAnalyzing, error };
}

파일 보기

@ -0,0 +1,165 @@
/**
* GPU API
*
* (starsafire) ResNet101+DANet
* base64 JPEG POST /api/aerial/oil-detect
*
* 5 클래스: background(0), black(1), brown(2), rainbow(3), silver(4)
*/
import { api } from '@common/services/api';
// ── Types ──────────────────────────────────────────────────────────────────
export interface OilDetectionConfig {
captureIntervalMs: number; // API 호출 주기 (ms), default 5000
coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000
captureWidth: number; // 캡처 해상도 (너비), default 512
}
/** 유류 클래스 정의 */
export interface OilClass {
classId: number;
className: string;
color: [number, number, number]; // RGB
thicknessMm: number;
}
/** 개별 유류 영역 (API 응답에서 변환) */
export interface OilRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
areaM2: number;
thicknessMm: number;
}
/** 감지 결과 (오버레이에서 사용) */
export interface OilDetectionResult {
regions: OilRegion[];
totalPercentage: number;
totalAreaM2: number;
mask: Uint8Array; // 클래스 인덱스 (0-4)
maskWidth: number;
maskHeight: number;
timestamp: number;
}
// ── Constants ──────────────────────────────────────────────────────────────
export const DEFAULT_OIL_DETECTION_CONFIG: OilDetectionConfig = {
captureIntervalMs: 5000,
coverageAreaM2: 10000,
captureWidth: 512,
};
/** 유류 클래스 팔레트 (시립대 starsafire 기준) */
export const OIL_CLASSES: OilClass[] = [
{ classId: 1, className: 'black', color: [0, 0, 204], thicknessMm: 1.0 },
{ classId: 2, className: 'brown', color: [180, 180, 180], thicknessMm: 0.1 },
{ classId: 3, className: 'rainbow', color: [255, 255, 0], thicknessMm: 0.0003 },
{ classId: 4, className: 'silver', color: [178, 102, 255], thicknessMm: 0.0001 },
];
export const OIL_CLASS_NAMES: Record<number, string> = {
1: '에멀전(Black)',
2: '원유(Brown)',
3: '무지개막(Rainbow)',
4: '은색막(Silver)',
};
// ── Frame Capture ──────────────────────────────────────────────────────────
/**
* base64 JPEG .
*/
export function captureFrameAsBase64(
video: HTMLVideoElement,
targetWidth: number,
): string | null {
if (video.readyState < 2 || video.videoWidth === 0) return null;
const aspect = video.videoHeight / video.videoWidth;
const w = targetWidth;
const h = Math.round(w * aspect);
try {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0, w, h);
// data:image/jpeg;base64,... → base64 부분만 추출
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
return dataUrl.split(',')[1] || null;
} catch {
return null;
}
}
// ── API Inference ──────────────────────────────────────────────────────────
interface ApiInferenceRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
thicknessMm: number;
}
interface ApiInferenceResponse {
mask: string; // base64 uint8 array
width: number;
height: number;
regions: ApiInferenceRegion[];
}
/**
* GPU .
*/
export async function detectOilSpillAPI(
video: HTMLVideoElement,
config: OilDetectionConfig,
): Promise<OilDetectionResult | null> {
const imageBase64 = captureFrameAsBase64(video, config.captureWidth);
if (!imageBase64) return null;
const response = await api.post<ApiInferenceResponse>('/aerial/oil-detect', {
image: imageBase64,
});
const { mask: maskB64, width, height, regions: apiRegions } = response.data;
const totalPixels = width * height;
// base64 → Uint8Array
const binaryStr = atob(maskB64);
const mask = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
mask[i] = binaryStr.charCodeAt(i);
}
// API 영역 → OilRegion 변환 (면적 계산 포함)
const regions: OilRegion[] = apiRegions.map((r) => ({
classId: r.classId,
className: r.className,
pixelCount: r.pixelCount,
percentage: r.percentage,
areaM2: (r.pixelCount / totalPixels) * config.coverageAreaM2,
thicknessMm: r.thicknessMm,
}));
const totalPercentage = regions.reduce((sum, r) => sum + r.percentage, 0);
const totalAreaM2 = regions.reduce((sum, r) => sum + r.areaM2, 0);
return {
regions,
totalPercentage,
totalAreaM2,
mask,
maskWidth: width,
maskHeight: height,
timestamp: Date.now(),
};
}

파일 보기

@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise<void> {
await api.delete(`/board/${sn}`); await api.delete(`/board/${sn}`);
} }
/** 관리자 전용 삭제 — 소유자 검증 없음 */
export async function adminDeleteBoardPost(sn: number): Promise<void> {
await api.post('/board/admin-delete', { sn });
}
// ============================================================ // ============================================================
// 매뉴얼 API // 매뉴얼 API
// ============================================================ // ============================================================

파일 보기

@ -36,8 +36,8 @@ export interface HNSInputParams {
interface HNSLeftPanelProps { interface HNSLeftPanelProps {
activeSubTab: 'analysis' | 'list'; activeSubTab: 'analysis' | 'list';
onSubTabChange: (tab: 'analysis' | 'list') => void; onSubTabChange: (tab: 'analysis' | 'list') => void;
incidentCoord: { lon: number; lat: number }; incidentCoord: { lon: number; lat: number } | null;
onCoordChange: (coord: { lon: number; lat: number }) => void; onCoordChange: (coord: { lon: number; lat: number } | null) => void;
onMapSelectClick: () => void; onMapSelectClick: () => void;
onRunPrediction: () => void; onRunPrediction: () => void;
isRunningPrediction: boolean; isRunningPrediction: boolean;
@ -112,7 +112,7 @@ export function HNSLeftPanel({
}, [loadedParams]); }, [loadedParams]);
// 기상정보 자동조회 (사고 발생 일시 기반) // 기상정보 자동조회 (사고 발생 일시 기반)
const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime); const weather = useWeatherFetch(incidentCoord?.lat ?? 0, incidentCoord?.lon ?? 0, accidentDate, accidentTime);
// 물질 독성 정보 // 물질 독성 정보
const tox = getSubstanceToxicity(substance); const tox = getSubstanceToxicity(substance);
@ -272,15 +272,23 @@ export function HNSLeftPanel({
className="prd-i flex-1 font-mono" className="prd-i flex-1 font-mono"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord.lat.toFixed(4)} value={incidentCoord?.lat.toFixed(4) ?? ''}
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })} placeholder="위도"
onChange={(e) => {
const lat = parseFloat(e.target.value) || 0;
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
}}
/> />
<input <input
className="prd-i flex-1 font-mono" className="prd-i flex-1 font-mono"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord.lon.toFixed(4)} value={incidentCoord?.lon.toFixed(4) ?? ''}
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })} placeholder="경도"
onChange={(e) => {
const lon = parseFloat(e.target.value) || 0;
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
}}
/> />
<button className="prd-map-btn" onClick={onMapSelectClick}> <button className="prd-map-btn" onClick={onMapSelectClick}>
📍 📍
@ -290,7 +298,7 @@ export function HNSLeftPanel({
{/* DMS 표시 */} {/* DMS 표시 */}
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0" <div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}> style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')} {incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
</div> </div>
{/* 유출형태 + 물질명 */} {/* 유출형태 + 물질명 */}

파일 보기

@ -156,7 +156,7 @@ function DispersionTimeSlider({
export function HNSView() { export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns'); const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
const { user } = useAuthStore(); const { user } = useAuthStore();
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 }); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -184,6 +184,7 @@ export function HNSView() {
setCurrentFrame(0); setCurrentFrame(0);
setIsPuffPlaying(false); setIsPuffPlaying(false);
setInputParams(null); setInputParams(null);
setIncidentCoord(null);
hasRunOnce.current = false; hasRunOnce.current = false;
}, []); }, []);
@ -320,6 +321,11 @@ export function HNSView() {
try { try {
const params = paramsOverride ?? inputParams; const params = paramsOverride ?? inputParams;
if (!incidentCoord) {
alert('사고 지점을 먼저 지도에서 선택하세요.');
setIsRunningPrediction(false);
return;
}
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시) // 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord); const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
@ -694,7 +700,7 @@ export function HNSView() {
) : ( ) : (
<> <>
<MapView <MapView
incidentCoord={incidentCoord} incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation} isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={[]} oilTrajectory={[]}

파일 보기

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useRef } from 'react'
import { Source, Layer } from '@vis.gl/react-maplibre' import { useMap } from '@vis.gl/react-maplibre'
import type { OceanForecastData } from '../services/khoaApi' import type { OceanForecastData } from '../services/khoaApi'
interface OceanForecastOverlayProps { interface OceanForecastOverlayProps {
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
visible?: boolean visible?: boolean
} }
// 한국 해역 범위 (MapLibre image source용 좌표 배열) // 한국 해역 범위 [lon, lat]
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se] const BOUNDS = {
// [lon, lat] 순서 nw: [124.5, 38.5] as [number, number],
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [ ne: [132.0, 38.5] as [number, number],
[124.5, 33.0], // 남서 (제주 남쪽) se: [132.0, 33.0] as [number, number],
[124.5, 38.5], // 북서 sw: [124.5, 33.0] as [number, number],
[132.0, 38.5], // 북동 (동해 북쪽) }
[132.0, 33.0], // 남동
] // www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
function toProxyUrl(url: string): string {
return url.replace('https://www.khoa.go.kr', '')
}
/** /**
* OceanForecastOverlay * OceanForecastOverlay
* *
* 기존: react-leaflet ImageOverlay + LatLngBounds * MapLibre raster layer는 deck.gl ,
* : @vis.gl/react-maplibre Source(type=image) + Layer(type=raster) * WindParticleLayer와 canvas를 map .
* * z-index 500 WindParticleLayer(450) .
* MapLibre image source는 Map
*/ */
export function OceanForecastOverlay({ export function OceanForecastOverlay({
forecast, forecast,
opacity = 0.6, opacity = 0.6,
visible = true, visible = true,
}: OceanForecastOverlayProps) { }: OceanForecastOverlayProps) {
const [loadedUrl, setLoadedUrl] = useState<string | null>(null) const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
useEffect(() => { useEffect(() => {
if (!forecast?.filePath) return const map = mapRef?.getMap()
let cancelled = false if (!map) return
const img = new Image()
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
img.src = forecast.filePath
return () => { cancelled = true }
}, [forecast?.filePath])
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath const container = map.getContainer()
if (!visible || !forecast || !imageLoaded) { // canvas 생성 (최초 1회)
return null if (!canvasRef.current) {
} const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
container.appendChild(canvas)
canvasRef.current = canvas
}
return ( const canvas = canvasRef.current
<Source
id="ocean-forecast-image" if (!visible || !forecast?.imgFilePath) {
type="image" canvas.style.display = 'none'
url={forecast.filePath} return
coordinates={KOREA_IMAGE_COORDINATES} }
>
<Layer canvas.style.display = 'block'
id="ocean-forecast-raster" const proxyUrl = toProxyUrl(forecast.imgFilePath)
type="raster"
paint={{ function draw() {
'raster-opacity': opacity, const img = imgRef.current
'raster-resampling': 'linear', if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
}}
/> const { clientWidth: w, clientHeight: h } = container
</Source> canvas.width = w
) canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, w, h)
// 4개 꼭짓점을 픽셀 좌표로 변환
const nw = map!.project(BOUNDS.nw)
const ne = map!.project(BOUNDS.ne)
const sw = map!.project(BOUNDS.sw)
const x = Math.min(nw.x, sw.x)
const y = nw.y
const w2 = ne.x - nw.x
const h2 = sw.y - nw.y
ctx.globalAlpha = opacity
ctx.drawImage(img, x, y, w2, h2)
}
// 이미지가 바뀌었으면 새로 로드
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
const img = new Image()
img.dataset.src = proxyUrl
img.onload = draw
img.src = proxyUrl
imgRef.current = img
} else {
draw()
}
map.on('move', draw)
map.on('zoom', draw)
map.on('resize', draw)
return () => {
map.off('move', draw)
map.off('zoom', draw)
map.off('resize', draw)
}
}, [mapRef, visible, forecast?.imgFilePath, opacity])
// 언마운트 시 canvas 제거
useEffect(() => {
return () => {
canvasRef.current?.remove()
canvasRef.current = null
}
}, [])
return null
} }

파일 보기

@ -0,0 +1,48 @@
import { useMap } from '@vis.gl/react-maplibre'
interface WeatherMapControlsProps {
center: [number, number]
zoom: number
}
export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
const { current: map } = useMap()
const buttons = [
{
label: '+',
tooltip: '확대',
onClick: () => map?.zoomIn(),
},
{
label: '',
tooltip: '축소',
onClick: () => map?.zoomOut(),
},
{
label: '🎯',
tooltip: '한국 해역 초기화',
onClick: () => map?.flyTo({ center, zoom, duration: 1000 }),
},
]
return (
<div className="absolute top-4 right-4 z-10">
<div className="flex flex-col gap-2">
{buttons.map(({ label, tooltip, onClick }) => (
<div key={tooltip} className="relative group">
<button
onClick={onClick}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
{label}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-0 text-text-1 border border-border rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
{tooltip}
</div>
</div>
))}
</div>
</div>
)
}

파일 보기

@ -77,8 +77,6 @@ export function WeatherMapOverlay({
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
const color = getWindHexColor(station.wind.speed, isSelected) const color = getWindHexColor(station.wind.speed, isSelected)
const size = Math.min(40 + station.wind.speed * 2, 80)
return ( return (
<Marker <Marker
key={`wind-${station.id}`} key={`wind-${station.id}`}
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
anchor="center" anchor="center"
onClick={() => onStationClick(station)} onClick={() => onStationClick(station)}
> >
<div <div className="flex items-center gap-1 cursor-pointer">
style={{ <div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
width: size, <svg
height: size, width={24}
transform: `rotate(${station.wind.direction}deg)`, height={24}
}} viewBox="0 0 24 24"
className="flex items-center justify-center cursor-pointer" style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
> >
<svg {/* 위쪽이 바람 방향을 나타내는 삼각형 */}
width={size} <polygon
height={size} points="12,2 4,22 12,16 20,22"
viewBox="0 0 24 24" fill={color}
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }} opacity="0.9"
/>
</svg>
</div>
<span
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
className="text-xs font-bold leading-none"
> >
<path {station.wind.speed.toFixed(1)}
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16" </span>
stroke={color}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
</svg>
</div> </div>
</Marker> </Marker>
) )
})} })}
{/* 기상 데이터 라벨 — MapLibre Marker */} {/*
{enabledLayers.has('labels') && {enabledLayers.has('labels') &&
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
}} }}
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer" className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
> >
{/* 관측소명 */}
<div <div
style={{ style={{
color: textColor, color: textColor,
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
> >
{station.name} {station.name}
</div> </div>
{/* 수온 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
🌡 🌡
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.temperature.current.toFixed(1)} {station.temperature.current.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
°C
</span>
</div> </div>
</div> </div>
{/* 파고 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
🌊 🌊
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.wave.height.toFixed(1)} {station.wave.height.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m
</span>
</div> </div>
</div> </div>
{/* 풍속 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
💨 💨
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.wind.speed.toFixed(1)} {station.wind.speed.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m/s
</span>
</div> </div>
</div> </div>
</div> </div>
</Marker> </Marker>
) )
})} })}
*/}
</> </>
) )
} }

파일 보기

@ -1,5 +1,5 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre' import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox' import { MapboxOverlay } from '@deck.gl/mapbox'
import type { Layer } from '@deck.gl/core' import type { Layer } from '@deck.gl/core'
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl' import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer' import { WindParticleLayer } from './WindParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData' import { useWeatherData } from '../hooks/useWeatherData'
import { useOceanForecast } from '../hooks/useOceanForecast' import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
type TimeOffset = '0' | '3' | '6' | '9' type TimeOffset = '0' | '3' | '6' | '9'
@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
return null return null
} }
// 줌 컨트롤
function WeatherMapControls() {
const { current: map } = useMap()
return (
<div className="absolute top-4 right-4 z-10">
<div className="flex flex-col gap-2">
<button
onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
</button>
<button
onClick={() =>
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
>
&#x1F3AF;
</button>
</div>
</div>
)
}
/** /**
* WeatherMapInner Map (useMap / useControl ) * WeatherMapInner Map (useMap / useControl )
*/ */
@ -159,6 +128,9 @@ interface WeatherMapInnerProps {
oceanForecastOpacity: number oceanForecastOpacity: number
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast'] selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
onStationClick: (station: WeatherStation) => void onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
clickedLocation: { lat: number; lon: number } | null
} }
function WeatherMapInner({ function WeatherMapInner({
@ -168,6 +140,9 @@ function WeatherMapInner({
oceanForecastOpacity, oceanForecastOpacity,
selectedForecast, selectedForecast,
onStationClick, onStationClick,
mapCenter,
mapZoom,
clickedLocation,
}: WeatherMapInnerProps) { }: WeatherMapInnerProps) {
// deck.gl layers 조합 // deck.gl layers 조합
const weatherDeckLayers = useWeatherDeckLayers( const weatherDeckLayers = useWeatherDeckLayers(
@ -216,8 +191,31 @@ function WeatherMapInner({
stations={weatherStations} stations={weatherStations}
/> />
{/* 클릭 위치 마커 */}
{clickedLocation && (
<Marker
longitude={clickedLocation.lon}
latitude={clickedLocation.lat}
anchor="bottom"
>
<div className="flex flex-col items-center pointer-events-none">
{/* 펄스 링 */}
<div className="relative flex items-center justify-center">
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
</div>
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-primary-cyan" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{clickedLocation.lon.toFixed(3)}°E
</div>
</div>
</Marker>
)}
{/* 줌 컨트롤 */} {/* 줌 컨트롤 */}
<WeatherMapControls /> <WeatherMapControls center={mapCenter} zoom={mapZoom} />
</> </>
) )
} }
@ -225,6 +223,7 @@ function WeatherMapInner({
export function WeatherView() { export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
const { const {
selectedForecast, selectedForecast,
availableTimes, availableTimes,
@ -238,7 +237,7 @@ export function WeatherView() {
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null null
) )
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels'])) const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값) // 첫 관측소 자동 선택 (파생 값)
@ -343,12 +342,6 @@ export function WeatherView() {
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<div className="px-6">
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
🚨
</button>
</div>
</div> </div>
{/* Map */} {/* Map */}
@ -371,6 +364,9 @@ export function WeatherView() {
oceanForecastOpacity={oceanForecastOpacity} oceanForecastOpacity={oceanForecastOpacity}
selectedForecast={selectedForecast} selectedForecast={selectedForecast}
onStationClick={handleStationClick} onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
clickedLocation={selectedLocation}
/> />
</Map> </Map>
@ -396,6 +392,7 @@ export function WeatherView() {
/> />
<span className="text-xs text-text-2">🌬 </span> <span className="text-xs text-text-2">🌬 </span>
</label> </label>
{/*
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -405,6 +402,7 @@ export function WeatherView() {
/> />
<span className="text-xs text-text-2">📊 </span> <span className="text-xs text-text-2">📊 </span>
</label> </label>
*/}
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -482,8 +480,8 @@ export function WeatherView() {
key={`${time.day}-${time.hour}`} key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)} onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${ className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day && selectedForecast?.ofcFrcstYmd === time.day &&
selectedForecast?.hour === time.hour selectedForecast?.ofcFrcstTm === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold' ? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3' : 'bg-bg-2 text-text-3 hover:bg-bg-3'
}`} }`}
@ -499,9 +497,9 @@ export function WeatherView() {
{oceanError && <div className="text-xs text-status-red"> </div>} {oceanError && <div className="text-xs text-status-red"> </div>}
{selectedForecast && ( {selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border"> <div className="text-xs text-text-3 pt-2 border-t border-border">
: {selectedForecast.name} {' '} : {selectedForecast.ofcBrnchNm} {' '}
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '} {selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
{selectedForecast.hour}:00 {selectedForecast.ofcFrcstTm}:00
</div> </div>
)} )}
</div> </div>

파일 보기

@ -70,9 +70,9 @@ export function useOceanForecast(
// 사용 가능한 시간대 목록 생성 // 사용 가능한 시간대 목록 생성
const availableTimes = forecasts const availableTimes = forecasts
.map((f) => ({ .map((f) => ({
day: f.day, day: f.ofcFrcstYmd,
hour: f.hour, hour: f.ofcFrcstTm,
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00` label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
})) }))
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`)) .sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))

파일 보기

@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) {
} }
const obs = await getRecentObservation(obsCode) const obs = await getRecentObservation(obsCode)
if (obs) { if (obs) {
const r = (n: number) => Math.round(n * 10) / 10 const r = (n: number) => Math.round(n * 10) / 10

파일 보기

@ -2,7 +2,7 @@
// API Key를 환경변수에서 로드 (소스코드 노출 방지) // API Key를 환경변수에서 로드 (소스코드 노출 방지)
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '' const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do' const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService' const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
// 지역 유형 (총 20개 지역) // 지역 유형 (총 20개 지역)
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
} as const } as const
export interface OceanForecastData { export interface OceanForecastData {
name: string // 지역 imgFileNm: string // 이미지 파일
type: string // 지역유형 imgFilePath: string // 이미지 파일경로
day: string // 예보날짜 (YYYYMMDD) ofcBrnchId: string // 해황예보도 지점코드
hour: string // 예보시간 (HH) ofcBrnchNm: string // 해황예보도 지점이름
fileName: string // 이미지 파일명 ofcFrcstTm: string // 해황예보도 예보시각
filePath: string // 해양예측 이미지 URL ofcFrcstYmd: string // 해황예보도 예보일자
} }
export interface OceanForecastResponse { interface OceanForecastApiResponse {
result: { header: { resultCode: string; resultMsg: string }
data: OceanForecastData[] body: {
meta: { items: { item: OceanForecastData[] }
totalCount: number pageNo: number
} numOfRows: number
totalCount: number
} }
} }
@ -49,9 +50,9 @@ export async function getOceanForecast(
): Promise<OceanForecastData[]> { ): Promise<OceanForecastData[]> {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
ServiceKey: API_KEY, serviceKey: API_KEY,
type: regionType, areaCode: regionType,
ResultType: 'json' type: 'json',
}) })
const response = await fetch(`${BASE_URL}?${params}`) const response = await fetch(`${BASE_URL}?${params}`)
@ -60,20 +61,8 @@ export async function getOceanForecast(
throw new Error(`HTTP Error: ${response.status}`) throw new Error(`HTTP Error: ${response.status}`)
} }
const data = await response.json() const data = await response.json() as OceanForecastApiResponse
return data?.body?.items?.item ?? []
// API 응답 구조에 따라 데이터 추출
if (data?.result?.data) {
return data.result.data
}
// 응답이 배열 형태인 경우
if (Array.isArray(data)) {
return data
}
console.warn('Unexpected API response structure:', data)
return []
} catch (error) { } catch (error) {
console.error('해황예보도 조회 오류:', error) console.error('해황예보도 조회 오류:', error)
@ -89,10 +78,9 @@ export async function getOceanForecast(
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null { export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
if (!forecasts || forecasts.length === 0) return null if (!forecasts || forecasts.length === 0) return null
// 날짜와 시간을 기준으로 정렬
const sorted = [...forecasts].sort((a, b) => { const sorted = [...forecasts].sort((a, b) => {
const dateTimeA = `${a.day}${a.hour}` const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
const dateTimeB = `${b.day}${b.hour}` const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
return dateTimeB.localeCompare(dateTimeA) return dateTimeB.localeCompare(dateTimeA)
}) })
@ -112,7 +100,7 @@ export function getForecastByTime(
targetHour: string targetHour: string
): OceanForecastData | null { ): OceanForecastData | null {
return ( return (
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
) )
} }
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
}) })
const response = await fetch(`${RECENT_OBS_URL}?${params}`) const response = await fetch(`${RECENT_OBS_URL}?${params}`)
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
} }
const data = await response.json() const data = await response.json()
const item = data?.result?.data?.[0] const item = data.body ? data.body.items.item[0] : null
if (!item) return null if (!item) return null
return { return {
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null, water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null, air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null, air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null, wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null, wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null, current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null, current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null, tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
} }
} catch (error) { } catch (error) {
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error) console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)

파일 보기

@ -12,6 +12,10 @@ export default defineConfig({
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/daily_ocean': {
target: 'https://www.khoa.go.kr',
changeOrigin: true,
},
}, },
}, },
resolve: { resolve: {