# Piratezone — 프로젝트 정보
선상 낚시 예약 서비스의 **사이트 관리자(Admin)** 와 사용자 사이트.
---
## 🏗️ 기술 스택
| | |
|---|---|
| 프론트엔드 | Nuxt 3 (Vue 3), SCSS, flatpickr (DatePicker) |
| 백엔드 | CodeIgniter 4 (PHP) |
| DB | MySQL / MariaDB |
| 폰트 | Pretendard (로컬, woff2 + woff) |
| 외부 API | Daum 우편번호, Google Geocoding |
| 암호화 | CI4 Encryption (AES-256-CTR + HMAC-SHA512) |
---
## ✅ 완료된 기능 (메뉴별)
### 🔐 인증 / 로그인
- **페이지**: `/site-manager` (로그인 폼, 좌우 50/50 레이아웃)
- **API**: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/check`
- 토큰 기반 인증, 비밀번호 변경 권장 모달, 계정 잠금 모달
- 로그인 후 `/site-manager/dashboard` 로 이동
### 👤 관리자 관리
- **테이블**:
- `admin_users` (id, username, password, password_changed_at, name, email, phone, role, status, login_attempts, last_failed_login, last_login, deleted_YN, created_at, updated_at)
- `admin_permissions` (id, admin_id FK CASCADE, permission VARCHAR, UNIQUE(admin_id, permission)) — 1:N 메뉴 권한
- **페이지**: `list / create / detail / edit` (`/site-manager/admin/...`)
- **API**:
- `GET /api/admin` (검색 search_field=username/name/email + role/status 필터 + `deleted=1`로 삭제 관리자만 분기)
- `GET /api/admin/check-username` (아이디 중복, soft 삭제된 계정 제외 → 재사용 가능)
- `GET /api/admin/:id` (permissions 같이 응답)
- `POST /api/admin` (생성 + permissions 동기화)
- `PUT /api/admin/:id` (role/permissions 변경은 슈퍼관리자만 가드)
- `DELETE /api/admin/:id` (soft delete + 토큰 무효화, permissions는 보존)
- `POST /api/admin/:id/restore` (복구 — username/email 충돌 검사)
- `DELETE /api/admin/:id/hard` (영구 삭제 — 이미 soft 삭제된 계정만, 본인 차단)
- `POST /api/admin/:id/password`, `POST /api/admin/:id/unlock`
- **role enum**: `super_admin`, `admin` (디폴트 `admin`)
- **status enum**: `active`, `inactive`, `suspended`
- **권한(`permissions`) enum**: `admin / field / fishing / challenge / quest / item / species / user` (= [admin.vue](app/layouts/admin.vue) menuItems id와 동일)
- 슈퍼관리자는 row 박지 않고 응답에 `'all'` 문자열로 반환
- 일반 admin은 메뉴 id 배열로 저장/응답 (1개 이상 필수)
- **주요 기능**:
- 검색(아이디/이름/이메일) + 권한·상태 필터 + 페이지네이션
- **체크박스 일괄 액션**: 선택 삭제 / 선택 활성 / 선택 휴면 / 선택 정지
- **삭제 관리자 관리 모드** (슈퍼관리자 전용): 같은 페이지 토글 → `deleted_YN='Y'`만 보여줌 → 선택 복구 / 선택 영구 삭제
- **CSV 내보내기** (현재 필터 그대로 또는 선택된 행만, UTF-8 BOM)
- **권한 칩 표시**: 슈퍼관리자는 골드 뱃지, 일반 admin은 메뉴 칩 + `+N` 더보기 (list 최대 2개, detail 전체 표시)
- **권한 기반 메뉴 분기** ([useAuth](app/composables/useAuth.js)) — 사이드바 메뉴 + URL 미들웨어 가드 ([middleware/auth.js](app/middleware/auth.js))
- **본인/슈퍼관리자 보호**: 체크박스 disabled, 수정/삭제 버튼 숨김, edit 페이지 진입 차단 (일반 admin이 슈퍼관리자 수정 시도 시 detail로 redirect)
- **비밀번호 변경 모달** (8자+, 일치 검증)
- 5회 실패 시 잠금 + 슈퍼관리자가 해제, 24시간 토큰
- 본인 계정 삭제 방지, 본인 계정 edit 시 상태 row 숨김
### 🎣 낚시분야 관리 (Field)
- **테이블**: `fishing_field` (id, name, weight, status_YN, deleted_YN, created_at, updated_at)
- **페이지**: list / create / detail / edit
- **API**: `GET /api/field/list`, `GET/POST/PUT/DELETE /api/field/:id`
- 분야명 1~30자, 중복 불가
- 가중치 0.0~1.0 (소수점 1자리 검증)
- 상태(사용중/미사용) 토글, soft delete
### 🗺️ 낚시지역 관리 (Area)
- **테이블**: `fishing_area` (id, name, deleted_YN, created_at, updated_at)
- **페이지**: list / create / detail / edit / **places (전체보기)**
- **API**:
- `GET /api/area/list`, `GET/POST/PUT/DELETE /api/area/:id`
- `GET /api/area/:id/places` — 해당 지역에 속한 onboard + fishing UNION ALL (필드명 JOIN), `limit` 모드(detail 미리보기 8개) / `page` 모드(전체보기 페이지네이션)
- 지역명 1~20자, 중복 불가, soft delete
- **상세 페이지 — 해당 지역의 낚시어선/낚시터 미니 테이블**: 카운트 표시(🎣 낚시터 N / 🚢 낚시어선 M), 최근 등록순 8개 + 전체보기 버튼, 행 클릭 시 onboard/fishing 상세로 이동
- **삭제 차단**: 해당 지역에 연결된 onboard/fishing이 1건이라도 있으면 백엔드/프론트 모두에서 삭제 거부 (`409 Conflict`)
- **전체보기 페이지** (`/area/places/:id`): 페이지네이션 + 6컬럼(번호/구분/이름/주소/상태/등록일) + 역순 번호 표시
### ⚓ 선상 관리 (Onboard)
- **테이블**: `onboard` (id, field_id, area_id, name, area_detail, tonnage, capacity, zip_code, address, address_detail, address_refer, lat, lng, bank_code, account_number(암호화), account_holder, partnership_YN, status_YN, deleted_YN, created_at, updated_at)
- **테이블**: `onboard_photos` (FK → onboard, ON DELETE CASCADE, hard delete)
- **페이지**: list / create / detail / edit
- **API**:
- `GET /api/onboard/list` (분야·지역 JOIN, 검색·필터·페이지네이션)
- `GET /api/onboard/:id` (사진 목록 포함, 계좌번호 복호화)
- `POST /api/onboard` (계좌번호 암호화)
- `PUT /api/onboard/:id` (계좌번호 재암호화)
- `DELETE /api/onboard/:id` (soft delete)
- `POST /api/onboard/:id/photos` (다중 사진 업로드, MIME 검증)
- `DELETE /api/onboard/photo/:photoId` (파일 + DB hard delete)
- **주요 기능**:
- 분야/지역 select (옵션 역순 정렬 — 수도권 등 초기 등록이 위로)
- **Daum 우편번호 검색** + 선택 시 **Google Geocoding 좌표 자동 입력**
- **계좌번호 양방향 암호화**: 저장 시 암호화, 상세/수정에서 복호화된 평문 표시
- 비제휴 선택 시 계좌 행 자동 숨김 + 값 초기화
- **다중 사진 업로드**: 미리보기, 개별 삭제, 첫 장 "대표" 뱃지, 10MB 제한
- **수정에서 기존 사진 삭제** (즉시 서버 반영, 파일 + DB)
- 검색 4종 (검색대상 select + 검색어 + 제휴 필터 + 상태 필터)
- **기간 검색** (등록일, DatePicker + 빠른 버튼: 오늘/7일/15일/1개월/3개월/1년)
- 행 클릭 → 상세, "수정" 버튼 → edit
### ⛵ 낚시터 관리 (Fishing)
- **테이블**: `fishing` (id, field_id, area_id, name, operating_hours, fish_species, zip_code, address, address_detail, address_refer, lat, lng, bank_code, account_number(암호화), account_holder, partnership_YN, status_YN, deleted_YN, created_at, updated_at)
- **테이블**: `fishing_photos` (FK → fishing, ON DELETE CASCADE, hard delete)
- **페이지**: list / create / detail / edit
- **API**:
- `GET /api/fishing/list` (분야·지역 JOIN, 검색·필터·페이지네이션)
- `GET /api/fishing/:id` (사진 목록 포함, 계좌번호 복호화)
- `POST /api/fishing` (계좌번호 암호화)
- `PUT /api/fishing/:id` (계좌번호 재암호화)
- `DELETE /api/fishing/:id` (soft delete)
- `POST /api/fishing/:id/photos` (다중 사진 업로드, MIME 검증)
- `DELETE /api/fishing/photo/:photoId` (파일 + DB hard delete)
- **주요 기능**:
- 선상 관리와 동일한 패턴 (분야/지역 select, 우편번호 검색, 좌표 자동, 계좌 암호화, 사진 다중 업로드, 검색 4종 + 기간검색)
- **낚시터 전용 컬럼**: `operating_hours` (운영시간 자유 텍스트), `fish_species` (주요 어종, 콤마 구분 VARCHAR)
- **어종 검색** 지원: 검색대상 select에 "주요어종" 옵션 추가, `fs.fish_species LIKE` 매칭
- 비제휴 선택 시 계좌 행 자동 숨김
- 행 클릭 → 상세, "수정" 버튼 → edit
### 🎁 아이템 관리 (Item)
- **테이블**: `item` (id, name, type, point, file_name, file_path, status_YN, deleted_YN, created_at, updated_at)
- **페이지**: list / create / detail / edit
- **API**:
- `GET /api/item/list` (검색·구분 필터·페이지네이션)
- `GET /api/item/:id`
- `POST /api/item` (multipart — 이미지와 텍스트 한 번에)
- `PUT /api/item/:id` (텍스트 필드 수정, JSON)
- `POST /api/item/:id/image` (이미지 교체, multipart, 기존 파일 자동 삭제)
- `DELETE /api/item/:id/image` (이미지 제거)
- `DELETE /api/item/:id` (soft delete)
- **주요 기능**:
- **구분 3종**: T(진출권) / P(포인트) / B(뱃지)
- **동적 폼**: 진출권 선택 시 포인트 행 자동 숨김 + 값 초기화
- **포인트 검증**: P 필수, B 선택 입력, T 미사용
- **단일 이미지 업로드**: 미리보기 + 교체/제거 분리 처리 (수정 시 새 이미지 > 제거 > 변경없음 우선순위)
- **검색**: 아이템명 입력 + 구분 필터 라디오 버튼 그룹(전체/진출권/포인트/뱃지)
- 행 클릭 → 상세, "수정" 버튼 → edit, soft delete
### 🐟 어종 관리 (Species)
세 개의 1뎁스 메뉴(어종구분 / 챌린지 어종관리 / 퀘스트 어종관리), 같은 코드 패턴 공유.
#### 🐠 어종구분 (Species Type)
- **테이블**: `species_type` (id, name, sort_order, status_YN, deleted_YN, created_at)
- **페이지**: 단일 list (인라인 추가/수정/삭제 + 일괄 저장)
- **API**: `GET /api/species/list`, `POST /api/species/bulk-save` (creates + updates + deletes 트랜잭션)
- 구분명 1~30자, 정렬순서 INT
#### 🏆 챌린지 어종관리 (Species Challenge)
- **테이블**: `species_challenge` (id, type_id NULL, name, min, max, round1_min/max ~ round5_min/max, deleted_YN, created_at)
- **페이지**: 단일 list (인라인 추가/수정/삭제 + 일괄 저장)
- **API**: `GET /api/species-challenge/list` (species_type JOIN, 구분 필터 + 어종명 검색 + 기간 검색), `POST /api/species-challenge/bulk-save`
- **검증** (프론트 + 백엔드 양방향):
- 어종명 필수 50자 이내
- 구분(type_id) **선택사항** — 미선택 행 있으면 일괄저장 시 확인 모달
- 최소금지 / 최대길이 입력, max ≥ min
- 각 라운드 min ≤ max, **모든 라운드 max ≤ 최대길이**
- **1라운드 min ≥ 최소금지** (사용자 명시 핵심 룰)
- 검색 구분 select에 "구분 없음" 옵션 (`type_id IS NULL`)
#### 🏅 퀘스트 어종관리 (Species Quest)
- **테이블**: `species_quest` (구조 동일)
- **페이지/API**: 챌린지와 100% 동일 코드 패턴 (테이블만 다름)
#### 공통 패턴 (3개 메뉴 공통)
- **인라인 추가/수정/삭제** + **일괄 저장** — 행 단위로 즉시 서버 호출 안 하고 변경 카운트 모아 한 번에 트랜잭션 처리
- **변경 카운트 표시** (`일괄 저장 (3)`), 변경 없으면 버튼 숨김
- **SVG 체크박스** (흰 바탕 + primary 컬러 체크/대시) — 테이블 전체 통일
- **토스트 알림** (`Teleport to="body"` + Transition, 하단 중앙, 자동 dismiss)
- **삭제 표시 모드**: 즉시 삭제 X, 화면에서 사라지고 일괄 저장 시 함께 처리
- **number input 4자리 제한** (`maxlength`는 number에서 무먹어서 `@input` 핸들러로 자르기) + spinner 화살표 숨김
### 🏆 챌린지 관리 (Challenge)
#### 📊 테이블 관계도 (⚠️ 6개 테이블 복합 구조)
```
challenge (마스터)
│ ├ fee, start_date, end_date(등록 후 수정 가능), max_participants(100~999999)
│ ├ total_rounds, closed_at, closed_by(NULL=자동, admin_id=수동)
│ ├ status_YN(노출여부), deleted_YN(soft delete)
│ └ description(TEXT, SunEditor HTML), file_name/file_path
│
├──── challenge_round (라운드, 2~5개)
│ │ ├ round_no, place_mode('all'|'specific'), qualified, closed_at
│ │
│ ├──── challenge_round_item ← all 모드: 라운드 단위 아이템
│ │ └ (round_id + item_id) UNIQUE
│ │
│ ├──── challenge_round_place ← specific 모드: 묶음의 장소들 (1선상=1row)
│ │ └ group_no(묶음 marker), place_type('onboard'|'fishing'), place_id
│ │
│ └──── challenge_round_group_item ← specific 모드: 묶음별 아이템
│ └ (round_id, group_no)로 묶음 매칭, item_id FK
│
└ FK: closed_by → admin_users.id (ON DELETE SET NULL)
challenge_draft (별개, 1인당 1개 임시저장)
└ admin_id UNIQUE FK → admin_users (ON DELETE CASCADE)
└ data JSON (폼 전체 통째로 저장)
```
#### 🔑 group_no 패턴 (specific 모드의 핵심)
프론트의 "장소 1, 장소 2"는 **묶음(group)** — 같은 분야/지역/제휴 조건으로 묶인 여러 선상/낚시터가 동일 아이템을 공유.
**DB 저장**:
- 묶음 1번에 선상 3개 + 아이템 2개면 → `challenge_round_place` 3 row + `challenge_round_group_item` 2 row
- `group_no`로 묶음 식별 (등록 시 1, 2, 3... 순차 부여)
- ⚠️ `group_no`는 **FK 아닌 의미적 marker** — `challenge_round_place`의 `(round_id, group_no)`가 UNIQUE 아니라서 FK 불가
- 정합성은 **백엔드 코드가 보장** (`ChallengeController::create()/update()`에서 같은 `group_no`로 묶어 INSERT)
**조회 시 묶음 복원**:
- `challenge_round_place`를 `group_no`로 PHP foreach 그룹화
- 각 그룹의 아이템은 `challenge_round_group_item WHERE round_id=? AND group_no=?`로 가져옴
#### 📄 페이지
| 경로 | 역할 |
|---|---|
| `/site-manager/challenge/list` | 목록 (상태 필터/검색/기간) |
| `/site-manager/challenge/create` | 등록 (라운드/장소/아이템 다 입력) |
| `/site-manager/challenge/detail/[id]` | 상세 (메인 탭 3종 + 라운드 탭) |
| `/site-manager/challenge/edit/[id]` | 수정 (기존 데이터 폼 채움) |
#### 🔌 API
| 메서드/경로 | 설명 |
|---|---|
| `GET /api/challenge/list` | 목록 + 상태 카운트 5종 (recruiting/running/ended/hidden/all) |
| `GET /api/challenge/:id` | 상세 (rounds + items + places + group_items 전체 트리) |
| `POST /api/challenge` | 등록 (트랜잭션, 5개 테이블 INSERT) |
| `PUT /api/challenge/:id` | 수정 (자식 DELETE 후 재INSERT) |
| `POST /api/challenge/:id/image` | 타이틀 이미지 업로드 |
| `DELETE /api/challenge/:id/image` | 이미지 제거 |
| `POST /api/challenge/round/:round_id/close` | **라운드 마감** (마지막 라운드면 challenge.closed_at 자동 설정) |
| `DELETE /api/challenge/:id` | soft delete (`deleted_YN='Y'`) |
#### 🚦 상태 계산 (derived_status — SQL CASE)
list/detail 둘 다 SQL에서 실시간 계산 (별도 cron 불필요).
**우선순위:**
```sql
CASE
WHEN status_YN = 'N' THEN 'hidden' -- list만 (detail은 제외)
WHEN closed_at IS NOT NULL THEN 'ended' -- 명시적 마감 (관리자 강제 or 자동 종료)
WHEN end_date < NOW() THEN 'ended' -- 종료일 경과 ⏰
WHEN start_date > NOW() THEN 'recruiting' -- 모집중
ELSE 'running' -- 진행중
END
```
- **list**: 4종(`hidden/recruiting/running/ended`) — 비노출 표시
- **detail**: 3종(`recruiting/running/ended`) — 노출여부는 별도 row로 표시되므로 hidden 제외
- **카운트 5종 응답** (`counts.all/recruiting/running/ended/hidden`)
#### 🔢 현재 라운드 (current_round)
list의 `R{현재}/{총}` 표시용. SQL subquery로 계산:
```sql
CASE
WHEN closed_at IS NOT NULL THEN total_rounds -- 챌린지 종료
WHEN end_date < NOW() THEN total_rounds -- 종료일 경과
ELSE COALESCE(
(SELECT MIN(round_no) FROM challenge_round
WHERE challenge_id = challenge.id AND closed_at IS NULL),
total_rounds
)
END AS current_round
```
#### 🏁 라운드 마감 → 자동 챌린지 종료
`closeRound()` 트랜잭션:
1. `challenge_round.closed_at = NOW()`
2. 남은 미마감 라운드 0개면 → `challenge.closed_at = NOW()`, `closed_by = NULL` (자동)
3. detail에서 "라운드 마감" 버튼은 **현재 라운드(currentRoundIdx) + 챌린지 미종료**일 때만 노출
#### 🎨 detail 페이지 UI 구조
- **메인 탭** (`.admin--main-tabs`): 챌린지관리 / 신청자관리(준비중) / 참가자관리(준비중)
- **라운드 탭** (`.admin--round--tabs`): 라운드별 콘텐츠 전환 + 마감된 라운드는 "종료" 뱃지
- **활성 탭 자동 설정**: 페이지 진입 시 `activeRoundIdx` = 현재 라운드
- 라운드 마감/챌린지 삭제 모두 `AdminAlertModal`(type="confirm") 사용
#### 🖼️ edit 페이지 — 기존 이미지 처리
- `existingImagePath`/`existingImageName` ref로 기존 이미지 분리
- 신규 선택(`image`) 시 미리보기 우선, 없으면 기존 표시
- "이미지 제거" 버튼 → 즉시 `DELETE /api/challenge/:id/image` 호출
#### 💰 참가비 (fee) — 무료 체크박스
- `type="number" min="0"` + `원`
- "무료" 체크박스 → `isFree=true` 시 input disabled + `fee='0'` 자동 입력
- edit 진입 시 `fee === 0` → `isFree` 자동 ON
#### 📋 등록/수정 검증 (백엔드 + 프론트 양방향)
- 챌린지명 1~255자
- 참가비 필수 (0 허용 = 무료)
- 시작일/종료일 YYYY-MM-DD, **end_date ≥ start_date**
- max_participants **100 ~ 999,999** (1라운드 정원)
- 라운드 **2~5개**, 각 라운드 `qualified > 0`
- specific 모드: **장소(묶음) 최소 1개**, 각 묶음에 **선상/낚시터 최소 1개**
- 아이템 0개 허용
#### 📝 임시저장 (challenge_draft) — 1인 1개
- `admin_id UNIQUE FK` (DB 레벨 강제)
- `data JSON` — 폼 전체(`rounds` 포함) 통째로 저장
- 등록 페이지 진입 시 모달로 "임시저장 불러올래?" 처리 예정 (현재 테이블만 준비)
---
## 🟡 작업 예정 (메뉴)
- 📊 대시보드 (현재 빈 페이지)
- 🏆 챌린지 신청자관리/참가자관리 탭 콘텐츠
- 🎯 퀘스트 관리 (생성 페이지 작업중 — 단독진행/챌린지 연동 토글)
- 👥 회원 관리
---
## ⚙️ 환경 설정 메모
### 프론트 `.env` (Nuxt 루트)
```
NUXT_PUBLIC_API_BASE=... # 백엔드 base URL
NUXT_PUBLIC_IMAGE_BASE=... # 이미지 호스트 (운영 도메인)
NUXT_PUBLIC_MEDIA_BASE=...
NUXT_PUBLIC_GOOGLE_MAP_KEY=... # Google Geocoding 키 (위도, 경도 가져오는 데에 사용[타 API 이용시 별도 키 필요하여 구 피싱포엠 형식 따름])
```
### 백엔드 `.env` (backend/)
```
encryption.key = hex2bin:... # 계좌번호 암호화 키 (분실 시 복호화 불가)
```
### 백엔드 타임존
[backend/app/Config/App.php](backend/app/Config/App.php) — `appTimezone = 'Asia/Seoul'`
### 업로드 위치
`backend/public/uploads/{도메인}/` (기존 패턴과 통일, 웹 직접 서빙)
- 선상 사진: `backend/public/uploads/onboard/`
- 낚시터 사진: `backend/public/uploads/fishing/`
- 아이템 이미지: `backend/public/uploads/item/`
### 이미지 URL 생성 ([useImage.js](app/composables/useImage.js))
- 1순위: `NUXT_PUBLIC_IMAGE_BASE` 환경변수
- 2순위(fallback): `NUXT_PUBLIC_API_BASE`의 origin 자동 추출 → **로컬에서 imageBase 비워도 이미지 표시됨**
---
## 🗑️ 정리한 컨트롤러 / 라우트
ERD 새로 짜면서 기존 비즈니스 컨트롤러(Branch, Showroom, Service, Brochure, Event, News, Notice, IR, Advisor, Popup, Basic, SalesStaff, BranchManager, Test) 전부 삭제. 남긴 것: Auth / Admin / BaseApi / Ping / Dashboard / Upload / Home.
---
## 📌 디자인 정책
- **soft delete vs hard delete**
- 마스터성 데이터(분야/지역/선상/낚시터/아이템/어종/관리자): **soft delete** (`deleted_YN`)
- 종속 데이터(선상/낚시터 사진): **hard delete** + 파일 직접 삭제
- 관리자 영구 삭제(`hardDelete`)는 슈퍼관리자가 "삭제 관리자 관리" 모드에서만 수행
- **TIMESTAMP 사용 시 주의** — MySQL이 첫 TIMESTAMP에 자동으로 `ON UPDATE CURRENT_TIMESTAMP` 부여하므로 `created_at`은 `DEFAULT CURRENT_TIMESTAMP`만, `updated_at`은 `NULL DEFAULT NULL`로 정의
- **계좌번호 컬럼 길이** — 암호화하면 base64로 ~135자 → `VARCHAR(255)` 권장
- **신규 SCSS 추가 시** — 어드민 페이지의 `