선상 낚시 예약 서비스의 사이트 관리자(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 레이아웃)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/...)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/unlocksuper_admin, admin (디폴트 admin)active, inactive, suspendedpermissions) enum: admin / field / fishing / challenge / quest / item / species / user (= admin.vue menuItems id와 동일)
'all' 문자열로 반환deleted_YN='Y'만 보여줌 → 선택 복구 / 선택 영구 삭제+N 더보기 (list 최대 2개, detail 전체 표시)fishing_field (id, name, weight, status_YN, deleted_YN, created_at, updated_at)GET /api/field/list, GET/POST/PUT/DELETE /api/field/:idfishing_area (id, name, deleted_YN, created_at, updated_at)GET /api/area/list, GET/POST/PUT/DELETE /api/area/:idGET /api/area/:id/places — 해당 지역에 속한 onboard + fishing UNION ALL (필드명 JOIN), limit 모드(detail 미리보기 8개) / page 모드(전체보기 페이지네이션)409 Conflict)/area/places/:id): 페이지네이션 + 6컬럼(번호/구분/이름/주소/상태/등록일) + 역순 번호 표시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)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)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)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)operating_hours (운영시간 자유 텍스트), fish_species (주요 어종, 콤마 구분 VARCHAR)fs.fish_species LIKE 매칭item (id, name, type, point, file_name, file_path, status_YN, deleted_YN, created_at, updated_at)GET /api/item/list (검색·구분 필터·페이지네이션)GET /api/item/:idPOST /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)세 개의 1뎁스 메뉴(어종구분 / 챌린지 어종관리 / 퀘스트 어종관리), 같은 코드 패턴 공유.
species_type (id, name, sort_order, status_YN, deleted_YN, created_at)GET /api/species/list, POST /api/species/bulk-save (creates + updates + deletes 트랜잭션)species_challenge (id, type_id NULL, name, min, max, round1_min/max ~ round5_min/max, deleted_YN, created_at)GET /api/species-challenge/list (species_type JOIN, 구분 필터 + 어종명 검색 + 기간 검색), POST /api/species-challenge/bulk-savetype_id IS NULL)species_quest (구조 동일)일괄 저장 (3)), 변경 없으면 버튼 숨김Teleport to="body" + Transition, 하단 중앙, 자동 dismiss)maxlength는 number에서 무먹어서 @input 핸들러로 자르기) + spinner 화살표 숨김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 (폼 전체 통째로 저장)
프론트의 "장소 1, 장소 2"는 묶음(group) — 같은 분야/지역/제휴 조건으로 묶인 여러 선상/낚시터가 동일 아이템을 공유.
DB 저장:
challenge_round_place 3 row + challenge_round_group_item 2 rowgroup_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] |
수정 (기존 데이터 폼 채움) |
| 메서드/경로 | 설명 |
|---|---|
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') |
list/detail 둘 다 SQL에서 실시간 계산 (별도 cron 불필요).
우선순위:
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
hidden/recruiting/running/ended) — 비노출 표시recruiting/running/ended) — 노출여부는 별도 row로 표시되므로 hidden 제외counts.all/recruiting/running/ended/hidden)list의 R{현재}/{총} 표시용. SQL subquery로 계산:
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() 트랜잭션:
challenge_round.closed_at = NOW()challenge.closed_at = NOW(), closed_by = NULL (자동).admin--main-tabs): 챌린지관리 / 신청자관리(준비중) / 참가자관리(준비중).admin--round--tabs): 라운드별 콘텐츠 전환 + 마감된 라운드는 "종료" 뱃지activeRoundIdx = 현재 라운드AdminAlertModal(type="confirm") 사용existingImagePath/existingImageName ref로 기존 이미지 분리image) 시 미리보기 우선, 없으면 기존 표시DELETE /api/challenge/:id/image 호출type="number" min="0" + <span>원</span>isFree=true 시 input disabled + fee='0' 자동 입력fee === 0 → isFree 자동 ONqualified > 0admin_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 — appTimezone = 'Asia/Seoul'
backend/public/uploads/{도메인}/ (기존 패턴과 통일, 웹 직접 서빙)
backend/public/uploads/onboard/backend/public/uploads/fishing/backend/public/uploads/item/NUXT_PUBLIC_IMAGE_BASE 환경변수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.
deleted_YN)hardDelete)는 슈퍼관리자가 "삭제 관리자 관리" 모드에서만 수행ON UPDATE CURRENT_TIMESTAMP 부여하므로 created_at은 DEFAULT CURRENT_TIMESTAMP만, updated_at은 NULL DEFAULT NULL로 정의VARCHAR(255) 권장<style scoped> 안 만들고 app/assets/scss/admin.scss 에 통합 (일관성 + 관리 단순화)INT/INT UNSIGNED/BIGINT)이 정확히 같아야 함. CHARSET/COLLATE도 동일해야 함.admin--modal-overlay + .admin--alert-modal admin--form-modal 조합. AdminAlertModal/비밀번호 변경 모달 등 전부 같은 톤(헤더 primary 네이비 배경, 본문 라운드 input, footer .admin--btn 체계)<Teleport to="body"> — 부모 컨테이너의 transform/overflow가 position: fixed를 가두는 경우 대비. 알림 모달은 .admin--alert-overlay { z-index: 10010 }로 다른 모달 위에 표시localStorage.admin_user.permissions 기반으로 사이드바 메뉴 필터링SQL CASE WHEN ... NOW() ...로 매 조회마다 실시간 계산 (derived_status, current_round). escape 이슈 때문에 select 두 번째 인자 false 필수DELETE FROM challenge_round WHERE challenge_id=?)group_no 같은 의미적 INT marker로. FK 강제 못 하므로 백엔드 코드가 정합성 보장$row 같은 변수명을 outer/inner 양쪽에 쓰면 outer 변수가 덮어쓰여 응답이 잘못 나감 (자주 발생한 버그). inner는 $pr, $placeRows 등 다른 이름 사용existingImagePath(기존) vs image(신규) ref 분리해서 미리보기 우선순위 처리. 신규 있으면 신규, 없으면 기존 표시. "이미지 제거"는 즉시 DELETE API 호출INT 컬럼에 0 허용. 프론트는 무료 체크박스 추가 → input disabled + '0' 자동 입력. edit 진입 시 fee === 0 → 체크박스 자동 ON