595 lines
21 KiB
Markdown
595 lines
21 KiB
Markdown
# 랜딩 페이지 SaaS 전략 문서
|
|
|
|
작성일: 2026-03-01
|
|
|
|
## 1. 제품 방향
|
|
- 대상: 광고 운영자가 고객 랜딩 페이지를 빠르게 생성·수정·배포·분석 가능한 SaaS
|
|
- 핵심 가치: "빠른 제작 + 안정적인 발행 + 성과 측정 + 팀 협업"
|
|
- 기술 스택(기준): Nuxt 4, Elysia, Prisma, SQLite(개발), PostgreSQL(운영), Bun, Nuxt UI, Pinia
|
|
|
|
## 2. 아키텍처 개요
|
|
- Frontend: Nuxt 4 + Nuxt UI + Pinia
|
|
- Backend: Elysia REST API
|
|
- ORM: Prisma
|
|
- DB: 개발 SQLite, 운영 PostgreSQL
|
|
- 파일/정적 자산: Object Storage + CDN
|
|
- 배포: Docker + CI/CD
|
|
|
|
## 3. 구현 우선순위(권장)
|
|
|
|
### 3.1 MVP (필수)
|
|
- 사용자/조직 관리 (회원가입, 로그인, 역할 기반 접근)
|
|
- 프로젝트/페이지 CRUD
|
|
- 랜딩 페이지 빌더(섹션 단위 블록)
|
|
- 초안/발행 상태 관리(드래프트/퍼블리시/아카이브)
|
|
- 페이지 미리보기
|
|
- 폼 제출 수집(기본 리드 저장)
|
|
- 기본 분석 이벤트(조회/클릭/제출)
|
|
- 템플릿 기반 시작 템플릿 제공
|
|
|
|
### 3.2 확장판
|
|
- 버전 히스토리 + 롤백
|
|
- 커스텀 도메인 연결
|
|
- 캠페인별 KPI 대시보드
|
|
- 자동화 알림(이메일/슬랙)
|
|
- 결제/구독 모델(요금제)
|
|
- 팀 협업(댓글, 승인 플로우)
|
|
|
|
## 4. 데이터/도메인 모델 핵심 엔티티
|
|
- User: 인증/프로필
|
|
- Team: 팀/고객사 단위 소유권
|
|
- Project: 고객별 랜딩 프로젝트
|
|
- LandingPage: 페이지 메타 정보
|
|
- LandingPageVersion: 버전 히스토리
|
|
- PublishLog: 발행 이력
|
|
- FormSubmission: 랜딩 폼 제출
|
|
- LeadEvent: 트래킹 이벤트
|
|
- Subscription(선택): 요금제/사용량
|
|
|
|
## 5. 기능 상세 체크리스트
|
|
|
|
### 인증/권한
|
|
- JWT 또는 세션 기반 인증
|
|
- 역할: Owner/Admin/Editor/Viewer
|
|
- 팀 단위 ACL + 프로젝트 단위 권한
|
|
- 감사 로그 필수
|
|
|
|
### 멀티테넌시
|
|
- team_id/project_id 기반 강제 분리
|
|
- 리소스 조회 시 항상 소유권 조건 적용
|
|
- 교차 접근 방지 테스트 필수
|
|
|
|
### 배포/발행
|
|
- 페이지 상태: draft, scheduled, published, archived
|
|
- 발행 동작은 큐 기반 비동기 처리 고려
|
|
- 발행 로그/실패 사유 저장
|
|
|
|
### SEO/마케팅 설정
|
|
- 메타 태그(og,title,description)
|
|
- 스크립트 태깅 (GA, 픽셀 등)
|
|
- OG 이미지/파비콘/robots 설정
|
|
|
|
### 분석/리포트
|
|
- 이벤트 모델 최소화: page_view, section_view, cta_click, form_submit
|
|
- 지표 집계(일 단위)
|
|
- 기간별 조회 및 CSV 내보내기
|
|
|
|
### 보안
|
|
- 입력 유효성/인젝션 방지
|
|
- rate limit, CORS, CSP, 보안 헤더
|
|
- 파일 업로드 확장자/용량 정책
|
|
|
|
### 운영
|
|
- 로그, 모니터링, 에러 추적(Sentry)
|
|
- 백업/복구 절차 정리
|
|
- 릴리스 전 Prisma migration 검사
|
|
|
|
## 6. SQLite → PostgreSQL 전환 전략
|
|
1. Prisma schema를 PostgreSQL/SQLite 타입 호환 위주로 작성
|
|
2. DB 특화 함수/타입(특히 JSON/날짜/enum) 사용 최소화
|
|
3. `prisma migrate` 기반 migration 기록을 운영 분기와 동기화
|
|
4. 전환 전:
|
|
- 마이그레이션 dry-run
|
|
- 샘플 데이터 이관 스크립트 작성
|
|
- 무중단 전환 테스트
|
|
|
|
## 7. 권장 개발 규칙
|
|
- TypeScript strict 사용
|
|
- ESLint/Prettier 통일
|
|
- API 계약 우선 (DTO, zod/valibot 등 검증)
|
|
- 페이지 빌더 스키마 버전 관리
|
|
- CI에서 테스트(요청 시 실행)
|
|
|
|
## 8. 다음 액션(요청하면 바로 진행)
|
|
- Prisma 스키마 초안 작성
|
|
- DB ERD/시퀀스 다이어그램 정리
|
|
- MVP API 엔드포인트 목록 도출
|
|
- 실제 폴더 구조 제안
|
|
|
|
## 9. 최소 라우트/페이지 설계(1차)
|
|
|
|
### 9.1 공개 라우트(인증 불필요)
|
|
- `/` 또는 `/{path?}`: 도메인 기반 랜딩 라우팅 진입점
|
|
- 동작: 요청 호스트 + path 기준으로 매핑된 랜딩 조회 후 렌더
|
|
- 조건 엔진 적용: 요일/시간/기간 규칙을 먼저 검사해 매칭 페이지 렌더
|
|
- `/_lead/success`: 폼 제출 성공 화면(선택)
|
|
- `/_lead/error`: 폼 제출 실패 화면(선택)
|
|
|
|
### 9.2 운영자 라우트(인증 필요)
|
|
- `/login`: 로그인
|
|
- `/admin` (Dashboard): 캠페인·리드 개요
|
|
- `/admin/campaigns`: 캠페인 목록
|
|
- `/admin/campaigns/new`: 캠페인 생성
|
|
- `/admin/campaigns/[id]/edit`: 캠페인 편집
|
|
- `/admin/pages`: 랜딩 페이지 목록(캠페인 필터)
|
|
- `/admin/pages/new`: 랜딩 페이지 생성
|
|
- `/admin/pages/[id]`: 랜딩 상세/조회
|
|
- `/admin/pages/[id]/builder`: 블록형 빌더 편집
|
|
- 하위 탭: 콘텐츠 / 블록 / 도메인 라우팅 / 조건 규칙 / 미리보기
|
|
- `/admin/pages/[id]/conditions`: 조건 규칙 전용(요청 시 별도 탭으로 분리 가능)
|
|
- `/admin/pages/[id]/routes`: 도메인+경로 매핑 관리
|
|
- `/admin/leads`: 리드 조회 목록
|
|
- `/admin/leads/[campaignId]`: 캠페인별 리드 목록
|
|
- `/admin/users`: 사용자/역할 관리(관리자, 리드관리자)
|
|
|
|
### 9.3 API 엔드포인트 권장(요청 도메인 라우팅/조건 렌더링 중심)
|
|
- `GET /api/public/route-by-host`: 호스트+path 기반 랜딩 조회
|
|
- `POST /api/public/lead`: 리드 생성(폼 제출)
|
|
- `GET /api/admin/campaigns`
|
|
- `POST /api/admin/campaigns`
|
|
- `GET /api/admin/pages`
|
|
- `POST /api/admin/pages`
|
|
- `GET/PUT /api/admin/pages/{id}/builder`
|
|
- `GET/POST /api/admin/pages/{id}/conditions`
|
|
- `POST /api/admin/pages/{id}/routes`
|
|
- `GET /api/admin/leads`
|
|
- `GET /api/admin/users`
|
|
|
|
### 9.4 1차 최소 화면(권장)
|
|
- 로그인
|
|
- 관리자 대시보드
|
|
- 캠페인 목록/생성/수정
|
|
- 랜딩 목록/빌더
|
|
- 조건 규칙 편집(요일/시간 슬롯)
|
|
- 도메인 라우팅 편집
|
|
- 리드 목록 조회
|
|
- 사용자/역할 관리(선택)
|
|
|
|
### 9.5 1차 제외(유지보수/확장으로 미루기)
|
|
- 버전관리/롤백
|
|
- 예약 발행
|
|
- 내부 트래픽 분석 대시보드
|
|
- 결제/구독
|
|
- 다국어/SEO 고도화
|
|
|
|
## 10. 1차 핵심 데이터 모델 설계(시작안)
|
|
|
|
### 10.1 엔티티 개요
|
|
- `Campaign`: 종목(광고 그룹)
|
|
- 한 종목 아래 여러 랜딩 페이지 보유
|
|
- `LandingPage`: 실제 렌더링 대상 페이지
|
|
- 블록 데이터 저장(이미지/폼/버튼 등)
|
|
- `LandingRoute`: 요청 도메인+경로의 기본 매핑
|
|
- 예: `aaa.com` + `/`, `aaa.com/google`
|
|
- `RouteCondition`: 특정 시간/요일 규칙 매칭 시 교체할 페이지 지정
|
|
- `Lead`: 폼 제출 데이터
|
|
- `User`: 관리자/리드관리자 계정
|
|
|
|
### 10.2 엔티티 관계
|
|
- Campaign 1:N LandingPage
|
|
- Campaign 1:N LandingRoute
|
|
- LandingRoute 1:N RouteCondition
|
|
- LandingRoute N:1 LandingPage (기본 랜딩)
|
|
- RouteCondition N:1 LandingPage (조건 매칭 시 보여줄 랜딩)
|
|
- LandingPage 1:N Lead
|
|
- Campaign 1:N Lead
|
|
|
|
### 10.3 권한 설계(최소)
|
|
- `ADMIN`
|
|
- 모든 관리자 화면 접근
|
|
- 캠페인/페이지/조건/도메인/사용자 관리
|
|
- `LEAD_MANAGER`
|
|
- 리드 목록 조회만 가능
|
|
|
|
### 10.4 최소 Prisma 스키마 초안(개념용)
|
|
```prisma
|
|
enum UserRole {
|
|
ADMIN
|
|
LEAD_MANAGER
|
|
}
|
|
|
|
model Campaign {
|
|
id String @id @default(cuid())
|
|
name String
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
landingPages LandingPage[]
|
|
routes LandingRoute[]
|
|
leads Lead[]
|
|
}
|
|
|
|
model LandingPage {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
name String
|
|
slug String?
|
|
blocks Json // 블록형 에디터 JSON
|
|
isDefault Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
routes LandingRoute[] @relation("RouteDefaultPage")
|
|
conditions RouteCondition[]
|
|
leads Lead[]
|
|
|
|
@@index([campaignId, isActive])
|
|
}
|
|
|
|
model LandingRoute {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
host String // 예: aaa.com
|
|
path String // 예: /, /google
|
|
defaultPageId String
|
|
defaultPage LandingPage @relation("RouteDefaultPage", fields: [defaultPageId], references: [id], onDelete: Restrict)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
conditions RouteCondition[]
|
|
leads Lead[]
|
|
|
|
@@unique([host, path])
|
|
}
|
|
|
|
model RouteCondition {
|
|
id String @id @default(cuid())
|
|
routeId String
|
|
route LandingRoute @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
|
pageId String
|
|
page LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
|
|
label String // 운영자 확인용
|
|
priority Int @default(0) // 높을수록 우선
|
|
isActive Boolean @default(true)
|
|
|
|
startDate DateTime?
|
|
endDate DateTime?
|
|
timezone String? @default("Asia/Seoul")
|
|
|
|
// 7자리 문자열: 0(sun)~6(sat) 1=적용
|
|
weekMask String @default("0000000")
|
|
|
|
// 분 단위(0~1439)
|
|
startMinute Int?
|
|
endMinute Int?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model Lead {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
pageId String
|
|
page LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
|
|
routeId String?
|
|
route LandingRoute? @relation(fields: [routeId], references: [id], onDelete: SetNull)
|
|
payload Json // 폼 데이터
|
|
sourceMeta Json? // ip, userAgent, referer 등
|
|
submittedAt DateTime @default(now())
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
password String // bcrypt 해시
|
|
role UserRole @default(ADMIN)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
```
|
|
|
|
### 10.5 조건 평가 규칙(우선순위)
|
|
1. 요청 호스트+path로 `LandingRoute` 조회
|
|
2. 해당 라우트의 `RouteCondition` 중 `isActive = true`만 대상
|
|
3. 현재 `day/time/date`가 조건(`weekMask`, `startMinute~endMinute`, `startDate~endDate`)에 일치하면 후보로 포함
|
|
4. 후보가 여러 개면 `priority desc, createdAt desc`로 정렬해 첫 번째 사용
|
|
5. 후보가 없으면 `defaultPageId` 페이지 렌더
|
|
|
|
### 10.6 렌더 우선순위 및 폴백
|
|
- 매칭은 `exact` 방식(요청 path와 저장된 path가 완전 일치)으로 수행
|
|
- 라우트 미존재: 404 또는 안내 페이지
|
|
- `startMinute`/`endMinute`가 교차 구간(예: 20:00~06:00)인 경우 자정 넘김 처리
|
|
|
|
### 10.7 버튼 블록 설정(카카오 포함)
|
|
- 공통: `buttonType`, `label`, `style`, `url`, `target`
|
|
- 카카오 버튼:
|
|
- `kakaoClientId`
|
|
- `kakaoSyncCode` 또는 `isKakaoSyncCodeInput`
|
|
- `redirectUri`
|
|
- 필요 시 스크립트 코드 블록
|
|
- 버튼 설정은 페이지 JSON 블록 단위로 저장
|
|
|
|
### 10.8 빌더 최소 블록 타입
|
|
- `hero`(문구/배경)
|
|
- `image_gallery`(이미지 나열)
|
|
- `form`(입력 폼)
|
|
- `button`(일반/카카오 버튼)
|
|
- `footer`
|
|
|
|
## 11. 생성 규칙(공식 방식 고정)
|
|
|
|
### 11.1 원칙
|
|
- Nuxt 4, Elysia, Prisma, Bun의 프로젝트 생성/설정은 공식 문서/공식 CLI 기준으로만 수행
|
|
- 임의 경로/구조를 새로 만들지 않고, 공식 권장 생성물을 우선 사용
|
|
- 추후 코드 생성은 공식 패턴의 레이어를 유지하며, 기존 공식 파일 위에 기능만 추가
|
|
|
|
### 11.2 프로젝트 초기화 규칙(요약)
|
|
- Nuxt는 공식 `nuxi` 초기화/레이아웃/라우트 규칙 준수
|
|
- Elysia는 공식 앱 생성 및 플러그인 미들웨어 등록 순서 준수
|
|
- Prisma는 `backend` 기준 `prisma init` + `backend/prisma/schema.prisma` + `migrate` 기준
|
|
- Bun은 공식 실행 스크립트/패키지 실행 규칙 준수
|
|
|
|
### 11.3 다음 작업 시 준수할 실행 순서(예정)
|
|
1. `Nuxt` 공식 생성 및 폴더 구조 확보
|
|
2. `Elysia` 공식 방식으로 API 엔트리 포인트 생성
|
|
3. `Prisma` 스키마를 공식 스키마 문법으로 정식화
|
|
4. `Pinia` 공식 store 패턴으로 상태영역 분리
|
|
5. 위 규칙에 맞는 페이지/라우트 파일만 추가
|
|
|
|
## 12. 네트워크/운영 결정 반영(최종 합의안)
|
|
|
|
### 12.1 환경 기본값
|
|
- 로컬 DB: `dev.db` (SQLite)
|
|
- 프론트엔드 포트: `3000`
|
|
- API 포트: `4000`
|
|
- 기본 타임존: `Asia/Seoul`
|
|
|
|
### 12.2 블록 편집 정책
|
|
- 블록은 드래그 앤 드롭으로 순서 변경 지원
|
|
- 핵심 블록 타입: 이미지 기반 섹션, 폼, 버튼, 푸터
|
|
- 카카오 버튼은 블록 단위 설정값으로 `kakaoSyncCode`(카카오 싱크 고유값) 입력 지원
|
|
|
|
### 12.3 권한 정책
|
|
- 역할: `ADMIN`, `LEAD_MANAGER` 2개로 확정
|
|
- `ADMIN`: 모든 관리 기능
|
|
- `LEAD_MANAGER`: 리드 조회만
|
|
|
|
### 12.4 도메인/경로 규칙
|
|
- 기본 도메인 예시: `aaa.com` 사용
|
|
- 경로 규칙은 현재 도메인+패스 매칭으로 처리하되, `/`(슬래시 단독 루트) 경로 사용
|
|
- 경로 충돌 방지를 위해 `aaa.com` 자체와 `/` 매핑을 동일한 기본 기준으로 운용
|
|
|
|
### 12.5 exact와 prefix의 의미(확인용)
|
|
- exact 매칭: 요청 경로가 저장된 경로와 완전히 같아야 매칭
|
|
- 예: 저장 경로가 `/google`이면 요청도 정확히 `/google`일 때만 매칭
|
|
- prefix 매칭: 저장된 경로가 요청 경로의 시작 부분이면 매칭
|
|
- 예: 저장 경로가 `/`이면 `/google`, `/landing` 모두 매칭(루트가 모든 경로 포괄)
|
|
- 현재 1차는 `exact`로 고정
|
|
|
|
## 13. 1차 정식 Prisma 스키마(실행형 초안)
|
|
|
|
> SQLite 기준(예: `backend/prisma/schema.prisma`)으로 작성. PostgreSQL 전환 시에는 `provider = "postgresql"`로 교체.
|
|
|
|
```prisma
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "sqlite"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
enum UserRole {
|
|
ADMIN
|
|
LEAD_MANAGER
|
|
}
|
|
|
|
model Campaign {
|
|
id String @id @default(cuid())
|
|
name String
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
landingPages LandingPage[]
|
|
routes LandingRoute[]
|
|
leads Lead[]
|
|
}
|
|
|
|
model LandingPage {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
name String
|
|
slug String?
|
|
blocks Json // 블록형 에디터 데이터(배열)
|
|
isDefault Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
defaultRoutes LandingRoute[] @relation("RouteDefaultPage")
|
|
conditions RouteCondition[]
|
|
leads Lead[]
|
|
|
|
@@index([campaignId, isActive])
|
|
}
|
|
|
|
model LandingRoute {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
host String // ex) aaa.com
|
|
path String // ex) /, /google
|
|
defaultPageId String
|
|
defaultPage LandingPage @relation("RouteDefaultPage", fields: [defaultPageId], references: [id], onDelete: Restrict)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
conditions RouteCondition[]
|
|
leads Lead[]
|
|
|
|
@@unique([host, path])
|
|
}
|
|
|
|
model RouteCondition {
|
|
id String @id @default(cuid())
|
|
routeId String
|
|
route LandingRoute @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
|
pageId String
|
|
page LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
|
|
label String
|
|
priority Int @default(0) // 높을수록 우선
|
|
isActive Boolean @default(true)
|
|
|
|
startDate DateTime?
|
|
endDate DateTime?
|
|
timezone String @default("Asia/Seoul")
|
|
|
|
// 7자 문자열(일~토) ex) 0111100
|
|
weekMask String @default("0000000")
|
|
|
|
startMinute Int?
|
|
endMinute Int?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model Lead {
|
|
id String @id @default(cuid())
|
|
campaignId String
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
pageId String
|
|
page LandingPage @relation(fields: [pageId], references: [id], onDelete: Restrict)
|
|
routeId String?
|
|
route LandingRoute? @relation(fields: [routeId], references: [id], onDelete: SetNull)
|
|
payload Json // 폼 submit payload
|
|
sourceMeta Json?
|
|
submittedAt DateTime @default(now())
|
|
|
|
@@index([campaignId, submittedAt])
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
password String
|
|
role UserRole @default(ADMIN)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
```
|
|
|
|
주의:
|
|
- 위 스키마는 개념 정리 목적이며, SQLite에서 지원되지 않는 제약/체크 조건은 실제 마이그레이션 환경에서 조정 필요.
|
|
- `RouteRelationDefaultPage`와 같은 별도 보조 모델은 사용하지 않음.
|
|
|
|
## 14. 라우팅/조건 매칭 알고리즘(1차: exact)
|
|
|
|
### 14.1 목적
|
|
- 요청 host/path에 대해 기본 페이지를 찾아서, 조건 규칙 매칭이 있으면 변형 페이지로 전환.
|
|
|
|
### 14.2 pseudocode
|
|
```ts
|
|
// Elysia 핸들러에서 사용
|
|
function isWeekMatch(weekMask: string, now: Date, timezone: string): boolean {
|
|
const tzNow = new Date(now.toLocaleString("en-US", { timeZone: timezone }))
|
|
const day = tzNow.getDay() // 0=Sun..6=Sat
|
|
return weekMask[day] === "1"
|
|
}
|
|
|
|
function inTimeRange(startMinute?: number | null, endMinute?: number | null, nowMinute: number): boolean {
|
|
if (startMinute == null || endMinute == null) return true // no time restriction
|
|
if (startMinute <= endMinute) {
|
|
return nowMinute >= startMinute && nowMinute <= endMinute
|
|
}
|
|
// cross midnight: 예) 20:00~06:00
|
|
return nowMinute >= startMinute || nowMinute <= endMinute
|
|
}
|
|
|
|
function inDateRange(startDate: Date | null, endDate: Date | null, now: Date): boolean {
|
|
if (startDate && now < startDate) return false
|
|
if (endDate && now > endDate) return false
|
|
return true
|
|
}
|
|
|
|
async function resolveLandingPage(host: string, path: string) {
|
|
const route = await prisma.landingRoute.findFirst({
|
|
where: { host, path, isActive: true },
|
|
include: { defaultPage: true, conditions: true }
|
|
})
|
|
if (!route) return null
|
|
|
|
const now = new Date()
|
|
const nowMinute = now.getHours() * 60 + now.getMinutes()
|
|
|
|
const matched = route.conditions
|
|
.filter((c) => c.isActive)
|
|
.filter((c) => inDateRange(c.startDate, c.endDate, now))
|
|
.filter((c) => isWeekMatch(c.weekMask, now, c.timezone ?? "Asia/Seoul"))
|
|
.filter((c) => inTimeRange(c.startMinute, c.endMinute, nowMinute))
|
|
.sort((a, b) => b.priority - a.priority || +new Date(b.updatedAt) - +new Date(a.updatedAt))
|
|
|
|
return matched.length > 0 ? matched[0].pageId : route.defaultPageId
|
|
}
|
|
```
|
|
|
|
### 14.3 라우트 우선순위
|
|
- 1단계: exact host+path 조회 (`AAA.com`, `/google` 정확히 일치)
|
|
- 2단계: 조건 일치 페이지 선택(우선순위/최신 업데이트 순)
|
|
- 3단계: 일치 조건 없으면 `defaultPageId`
|
|
|
|
## 15. 1차 API 계약(구현 시작점)
|
|
|
|
### 15.1 공개 API
|
|
- `GET /api/public/route-by-host`
|
|
- Query: `host`, `path`
|
|
- Response:
|
|
- `page`: blocks 포함 렌더 데이터
|
|
- `campaignId`, `pageId`, `routeId`
|
|
- `status`: `ok | not_found`
|
|
- `POST /api/public/lead`
|
|
- Body: `campaignId`, `pageId`, `routeId`, `payload`, `sourceMeta`
|
|
- Response: `{ ok: true }`
|
|
|
|
### 15.2 관리자 API(필수)
|
|
- `POST /api/admin/auth/login`
|
|
- `GET /api/admin/campaigns`, `POST /api/admin/campaigns`
|
|
- `GET /api/admin/pages`, `POST /api/admin/pages`, `GET /api/admin/pages/:id`, `PUT /api/admin/pages/:id`
|
|
- `GET /api/admin/pages/:id/route`, `PUT /api/admin/pages/:id/route`
|
|
- `GET /api/admin/pages/:id/conditions`, `POST /api/admin/pages/:id/conditions`, `PATCH /api/admin/pages/:id/conditions/:conditionId`
|
|
- `GET /api/admin/leads`
|
|
- `GET /api/admin/users`, `POST /api/admin/users`, `PUT /api/admin/users/:id/role`
|
|
|
|
### 15.3 공통 응답 규칙(제안)
|
|
- 인증 필요: `{ error: "unauthorized" }`
|
|
- 권한 부족: `{ error: "forbidden" }`
|
|
- 검증 실패: `{ error: "validation", details: [...] }`
|
|
|
|
## 16. 문서 분리(운영 모드)
|
|
- 전략/결정: `docs/strategy.md`
|
|
- DB/스키마: `docs/database.md`
|
|
- 라우팅/조건 엔진: `docs/routing-conditions.md`
|
|
- 화면/라우팅: `docs/ui-flow.md`
|
|
- API 계약: `docs/api-spec.md`
|
|
- 실행 코드(초안):
|
|
- Prisma 스키마: `backend/prisma/schema.prisma`
|
|
- 조건 라우팅 엔진: `backend/src/routing/resolveLandingPage.ts`
|
|
|
|
향후 구현을 진행할 때는 위 문서를 기준으로 업데이트하고, 필요 시 `landing-strategy.md`는 요약본으로 유지한다.
|