커미션 플랫폼 아키텍처
멀티 테넌트 커미션 플랫폼으로, 각 사용자(아티스트, 크리에이터, 프리랜서)는 주문 기능이 있는 공개 페이지를 갖습니다. 이 스킬은 내장 호스팅 템플릿과 자체 사이트를 보유한 크리에이터의 통합을 모두 제공하기 위한 아키텍처 결정을 다룹니다.
3계층 페이지 시스템
중앙 아키텍처 패턴: 기술 수준과 독립성에 따라 세 가지 수준으로 크리에이터를 지원합니다.
┌─────────────────────────────────────────────────┐
│ 레이어 3: 맞춤 페이지 호스팅 │
│ 자신의 HTML/CSS/JS 업로드 → /artist/alice에서 │
│ 제공 (uploads/ 디렉토리의 정적 파일) │
├─────────────────────────────────────────────────┤
│ 레이어 2: 임베드 가능 위젯 │
│ 기존 사이트에 <script> 태그 하나만 붙이면 │
│ → "커미션 의뢰" 버튼 + 주문 양식 │
├─────────────────────────────────────────────────┤
│ 레이어 1: 내장 템플릿 │
│ 레이아웃 변형 선택, 설정 불필요 │
│ → /artist/alice 전체 페이지 │
└─────────────────────────────────────────────────┘
레이어 1: 내장 템플릿
대상: 자체 웹사이트가 없거나 유지 관리하고 싶지 않은 크리에이터.
구현: CSS 변수 테마를 사용한 Vue 컴포넌트.
// 라우트: /artist/:subdomain
routes.get('/artist/:subdomain', async (req, reply) => {
const artist = await artistService.getBySubdomain(req.params.subdomain)
if (artist.page_type === 'default') {
// 내장 Vue SPA (또는 SSR) 렌더링
// page_theme 필드가 템플릿 변형을 제어함
return reply.sendFile('index.html')
}
// ... 다른 곳에서 레이어 2-3 처리로 넘어감
})
템플릿 = 레이아웃 × 팔레트 (직교 축) — 가장 중요한 모델링 결정입니다. 두 "템플릿"이 주로 색상만 다르다면, 그것은 두 개의 템플릿이 아니라 두 가지 색상 구성의 하나의 레이아웃입니다. 색상을 템플릿 차원에 묶으면 중복된 구조 코드로 템플릿 수가 급증합니다(사용자들이 지적할 것입니다: "이 페이지들은 색상만 다르고 같은 것 같아요"). 아티스트가 독립적으로 선택할 수 있는 축으로 분할하세요:
| 축 | 선택자 | 예시 | 제어 요소 |
|---|---|---|---|
| 레이아웃 (구조) | 아티스트 | classic (2열 스튜디오) / gallery (전체화면 갤러리) / folio (단일 페이지 랜딩) / atelier (책 스타일 웜 스튜디오) | 오프닝 처리, 열 로직, 갤러리 모양, 탐색 |
| 팔레트 (분위기) | 아티스트 | paper / ink / dusk / moss — 각각 밝은색과 어두운색 변형으로 정의 | 페이지 바닥/표면/텍스트 색상 (--pal-* CSS 변수) |
| 악센트 + 모드 | 방문자 | 5색 악센트 선택기 + 밝음/어두움 선호도 | --color-primary, html.dark |
핵심 규칙:
- 팔레트는 밝은색+어두운색 변형이 있는 분위기이지, "어두운 테마"가 아닙니다. 아티스트가
ink를 선택하면, 밝은 모드 방문자는 옅은 회색을, 어두운 모드 방문자는 차콜색을 봅니다 — 분위기는 일관되고 밝기는 방문자를 따릅니다. - 악센트 색상(버튼/링크/하이라이트)은 항상 방문자의 악센트 변수(
--color-primary)를 사용하며, 팔레트 변수를 사용하지 않습니다 — 두 시스템은 직교 상태를 유지하며 모두 계속 작동합니다. - 4개 레이아웃 × 4개 팔레트 = 8개 정의로 16가지 개별 외형.
- 레이아웃은 구조적으로 달라야 하며(다른 오프닝, 다른 열 로직), 그렇지 않으면 하나의 레이아웃으로 병합되어야 합니다.
작동하는 레이아웃 원형 — 각각 아티스트의 가장 독특한 자산(그들의 작품)으로 시작하며, 일반적인 중앙 정렬 이름+약력+버튼 히어로가 아닙니다:
| 레이아웃 | 오프닝 | 시그니처 동작 |
|---|---|---|
| gallery | 전체 뷰포트 작품, 이름은 작은 코너 명판 | 편집 스타일 대형/소형 교대 갤러리, 호버 확대, 클릭 미리보기 |
| classic | 시그니처 작품 배너, 작품 위에 이름 오버레이 | 데스크탑 2열, 고정 정보 카드와 항상 보이는 커미션 CTA |
| folio | 분할 화면: 왼쪽 텍스트, 오른쪽 작품 스택 | 스크롤 추적 네비게이션 하이라이트, 모바일 햄버거 + 하단 고정 CTA |
| atelier | 따뜻한 책 스타일 확산, 세리프 서체 (Noto Serif SC) | 종이 질감 바닥, 편집 여백, 넉넉한 여백, 문학적 분위기의 작품 중심 |
공유 키트 아키텍처(검증됨): 템플릿은 백엔드 필드 이름을 직접 읽지 않습니다 — 하나의 어댑터 컴포저블(useArtistData(props))이 imgUrl(), statusText() (i18n), socialLinks, heroArtwork를 제공합니다. 재사용 가능한 Tpl* 컴포넌트는 props를 통해 변형을 받습니다(TplHero variant="banner|fullscreen|split", TplGallery layout="grid|editorial|masonry") (티어 그리드의 #addons 슬롯을 확장 포인트로 사용). 전체 패턴(비동기 컴포넌트 타이밍 수정 및 레거시 ID 매핑 포함)은 references/template-system.md를 참조하세요.
아티스트 설정 악센트 색상 재정의 (R49 패턴): 아티스트는 공개 페이지에서 방문자의 ThemePicker 선택을 재정의하는 영구 악센트 색상(5색 화이트리스트, artists.accent_color TEXT로 저장)을 설정할 수 있습니다. ArtistHome.vue 구현:
const ACCENT_INDEX = { '#34dbcb': '1', '#34c2db': '2', '#3498db': '3', '#346edb': '4', '#3445db': '5' }
const accentOverride = computed(() => {
const raw = previewAccent.value || artist.value?.accentColor
return raw ? (ACCENT_INDEX[String(raw).toLowerCase()] || null) : null
})
// watch(immediate) + onUnmounted: save/restore document.documentElement.dataset.accent
핵심 결정: (1) 전역 data-accent 속성 삽입 — 템플릿별 CSS 변수 변경 불필요 (theme.css는 이미 data-accent="1"~"5" 선택자와 밝은색+어두운색 변형을 가지고 있음). 4개 템플릿 모두에 악센트 지원 추가 = 템플릿 파일 변경 0개. (2) 아티스트 페이지에 있는 동안 아티스트 악센트가 방문자 ThemePicker보다 우선함. 방문자 선택은 onUnmounted에서 복원됨. (3) 백엔드는 5색 화이트리스트 + null(지우기)에 대해 유효성을 검사함. 쓰기 시 toLowerCase() 정규화. (4) 설정 UI: 원형 스와치 버튼(32px) + 활성화 시 ✓ 체크 + "默认" 지우기 버튼. el-color-picker 아님(자유 형식 색상은 화이트리스트를 깨뜨림). (5) C61 결정: 악센트는 클라이언트 직면 페이지만 영향을 주며, 아티스트 대시보드에는 영향을 주지 않음.
URL 파라미터를 통한 미리보기 모드 (R50 패턴): 설정 "预览主页" 버튼이 새 창에서 /artist/:subdomain?_tpl=X&_pal=Y&_accent=Z를 엽니다. ArtistHome.vue는 route.query._tpl/_pal/_accent를 렌더링 레이어 재정의로 읽습니다(데이터 레이어에 쓰지 않음). 미리보기 파라미터가 있는 경우 고정 경고 배너("预览模式 — 修改尚未保存")가 표시됩니다. 템플릿/팔레트/악센트 계산 속성은 previewParam || artist.field || default로 대체됩니다. 공개 액세스에 안전함 — 미리보기 파라미터는 렌더링에만 영향을 주며, 모든 데이터는 공개 API에서 가져옴.
플랫 메모 목록을 대체하는 활동 타임라인 (R40 패턴): 상태 변경 기록(시스템 메모)과 아티스트 메모를 단일 연대기 보기로 병합할 때, 유형/중공 구분과 함께 el-timeline을 사용하세요:
<el-timeline-item :type="note.created_by === 'system' ? 'info' : (note.image_path ? 'success' : 'primary')"
:hollow="note.created_by === 'system'" :timestamp="formatDate(note.created_at)" placement="top">
유형 표시기: 🔄 상태 변경 / 📝 메모 / 🖼 이미지 포함 메모. 시스템 메모는 tl-item--system 클래스(희미한 색상, 작은 글꼴)를 얻습니다. 삭제 버튼(R46)은 시스템 메모가 아닌 경우에만 표시되며, @media (hover: none) 터치 폴백과 함께 호버 시 나타납니다. 입력 상자는 타임라인 하단으로 이동합니다. 백엔드 변경 0개 — 시스템 메모는 이미 order_notes에 created_by='system'으로 존재합니다. 작업 표시줄(진행/되돌리기/취소)은 타임라인 카드 외부에 유지됩니다.
컨텍스트 종속 확인 동작이 있는 드래그 교체 (R53 대 R55): 동일한 앱의 두 드래그-투-리플레이스 기능은 다른 확인 요구사항을 가집니다 — 통합하지 마세요:
| R53 큐 보드 초점 이미지 | R55 티어 예시 이미지 | |
|---|---|---|
| 덮어쓰기 확인 | 아니요 (이전 이미지는 주문 갤러리에 남음) | 예 — ElMessageBox.confirm (이전 이미지 손실) |
| 이유 | 초점 교체 ≠ 이미지 삭제 | 예시 덮어쓰기 = 이전 이미지 복구 불가 |
구현: 공유 드래그 인프라(dragover.prevent/dragleave with contains(relatedTarget) 안티-플리커/drop.prevent + 숨겨진 파일 입력)이지만, 업로드 함수는 R55에 대해서만 if (row.example_image) { await ElMessageBox.confirm(...) }를 확인합니다. 둘 다 기존 업로드 API를 재사용합니다. R53의 초점 이미지에서 el-image preview-src-list를 제거하여 클릭 이벤트를 삼키지 않도록 해야 합니다(R18과 동일한 함정). |
주문 폼 컴포저블 추출 (R58-1 패턴): 주문 폼이 ~400줄(데이터 로딩 + 가격 책정 + 유효성 검사 + 업로드 + 제출 + 초안 복구)을 초과하면, 모든 비즈니스 로직을 공유 컴포저블(useOrderForm.js)로 추출하세요. 페이지 컴포넌트는 순수 레이아웃이 됩니다(~300줄의 템플릿 + 스타일, 스크립트는 const of = useOrderForm(subdomain, formRef) + 구조 분해만). 컴포저블은 관심사별로 그룹화된 refs/reactives/methods의 평면 객체를 반환합니다: 데이터 로딩(artist, tiers, rulesContent, loading), 폼 상태 + 유효성 검사 규칙, 제출 상태(submitting, showSuccess, resultNo, submit), 참조 이미지(refFileList, handleRefUpload, handleRefRemove), 가격 책정(addonSelections, pricePreview, pricingExpanded, selectedTier, onTierChange), 정리된 규칙 HTML. 다중 템플릿에 중요한 이유: 주문 페이지가 여러 레이아웃 템플릿(단계별, 영수증 스타일, 잡지 스타일)을 지원할 때, 각 템플릿은 동일한 컴포저블을 호출하는 얇은 레이아웃 셸입니다 — 로직 중복 0. 컴포저블은 "코어", 템플릿은 "스킨"입니다. 이는 홈페이지의 useArtistData와 동일한 어댑터 패턴이며, 주문 폼에 적용된 것입니다. 핵심 구현 세부사항: 컴포저블은 formRef (템플릿의 el-form ref)를 파라미터로 받습니다. 왜냐하면 유효성 검사(formRef.value.validate())는 템플릿의 폼 요소를 호출해야 하기 때문입니다 — 컴포저블은 로직을 소유하지만 DOM은 소유하지 않습니다.
단계별 주문 폼 레이아웃 (R58-2 패턴, 검증됨): 기본 주문 레이아웃은 useOrderForm() 위에 완전히 구축된 3단계 가이드 흐름입니다(R58-1에서 컴포저블 변경 0 — 추출을 검증함):
- 단계 표시기: 3개의 번호가 매겨진 점 + 연결선. 현재: 확대 + 기본. 완료: ✓ + 기본 채우기. 보류 중: 회색 중공. 모바일 (≤860px): 점만.
- 1단계 — 티어 선택:
el-select를 대체하는 반응형 카드 그리드(repeat(auto-fill, minmax(240px, 1fr))). 선택됨:--ease-bounce전환 + 회전 스탬프 ✓ (@keyframes tier-stamp-in: scale 0→1.2→1, rotate -15°→0°). R14 점진적 공개는 1단계 내에 유지. - 2단계 — 설명 + 업로드: 텍스트 영역 + 영감 태그 (R58-4: 하드코딩된 기본 칩, 클릭 시 스마트 쉼표 + 2000자 잘림으로 추가) + 참조 업로드 (파일 + 붙여넣기 변경 없음).
- 3단계 — 연락처: QQ + 이름 + 규칙 동의.
- 고정 요약 카드: 와이드 스크린 오른쪽 열(280px 고정), 실시간 티어 + 내역 + 합계 + 할부. 모바일: 활성 단계 하단.
- 영수증 확인 (R58-3): 제출 → 유효성 검사 → 카페 영수증
el-dialog(본문으로 텔레포트,templates.css의 전역 CSSreceipt-*네임스페이스). 종이#fdfbf5, Courier 고정폭, 지그재그 가장자리(repeating-conic-gradient), 점선 구분선, 바코드 장식, 할부 칩. 고정 라이트 테마(종이 메타포). 취소는 3단계로 돌아감. 확인은submit()호출. - 주문 정보 복사 (R58-5): 성공 대화상자 "复制约稿信息" → 여러 줄 클립보드(주문 번호 + 티어 + 애드온 + 합계).
- 단계 탐색: "下一步"는 진행 전에 현재 단계의 유효성을 검사함. "上一步"는 항상 허용됨. 건너뛰기 없음.
다중 템플릿 주문 페이지 (R58 아키텍처): 주문 페이지는 홈페이지 템플릿과 독립적으로 아티스트가 선택 가능한 레이아웃 템플릿을 지원합니다. 백엔드 (검증됨, 마이그레이션 v16): artists.order_template_id TEXT DEFAULT 'default' — 서비스 계층 화이트리스트 유효성 검사(ORDER_TEMPLATES = ['default'], 템플릿 추가 시 배열 확장). GET /api/artists/:subdomain는 orderTemplateId: artist.order_template_id || 'default'를 반환합니다. PUT /api/artist/profile는 orderTemplateId를 허용합니다(keyMap을 통해 order_template_id에 매핑). 유효하지 않은 값은 INVALID_ORDER_TEMPLATE(409)를 발생시킵니다. 빈 문자열은 'default'로 대체됩니다. 프론트엔드: 아티스트 설정 페이지에는 카드 스타일 템플릿 선택기가 있습니다(홈페이지 템플릿 선택과 동일한 UI 패턴). 클라이언트 측 라우팅: OrderForm.vue는 artist.orderTemplateId를 읽고 <component :is="..."> 또는 템플릿 레지스트리 맵을 통해 해당 레이아웃 컴포넌트를 렌더링합니다. 모든 레이아웃 컴포넌트는 useOrderForm()을 공유합니다 — 시각적 배열(단계 표시기, 카드 레이아웃, 확인 대화상자 스타일)에서만 다릅니다. 새 주문 템플릿 추가 = 레이아웃 셸이 있는 하나의 새 .vue 파일 + 백엔드 화이트리스트 배열에 하나의 항목, 로직 변경 0. 미리보기: 홈페이지 미리보기의 _tpl URL 파라미터 메커니즘을 재사용합니다.
QQ 연락처 패턴 (R58-6): QQ 연락을 위한 두 가지 방법: "跳转QQ" (window.open('tencent://message/?uin=' + encodeURIComponent(qq), '_self')) + "复制QQ" (navigator.clipboard.writeText(qq) with toast). 클립보드 폴백: 실패 시(권한 거부, 비-HTTPS), ElMessage.warning(qq) 표시 — 경고 메시지 자체로 QQ 번호를 제공하여 사용자가 토스트에서 수동으로 복사할 수 있도록 함. 두 곳에 적용: 클라이언트 측 성공 대화상자(아티스트의 contactQq) 및 아티스트 측 주문 세부 정보(클라이언트의 qq). i18n: 컨텍스트별로 별도 키(orderForm.jumpQq 대 orderDetail.jumpQq).
CSS 제작 업그레이드 패턴 (v0.16 배치1, 검증됨):
- 전역 바운스 이징:
:root에--ease-bounce: cubic-bezier(.34, 1.56, .64, 1). transform/box-shadow 전환에만 사용(절대 opacity에 사용하지 마세요 — 1.0을 초과할 수 없음). 소비 코드의 폴백:var(--ease-bounce, cubic-bezier(0.22, 1, 0.36, 1)). - 버튼 3상태 물리적 모델:
.el-button:hover:not(:disabled):not(.is-loading) { transform: translateY(-3px); box-shadow: deeper }+:active { transform: translateY(-1px) scale(0.98) }.:not()제외는 비활성화/로딩 버튼이 애니메이션되는 것을 방지합니다. clamp()유동 타이포그래피:font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem)— 값이clamp()로 단순해질 때까지calc()가 있는 중첩min()/max()호출을 제거합니다.
레이어 2: 임베드 가능 위젯
대상: 자신의 웹사이트가 있고 단일 주문 진입점을 추가하려는 크리에이터.
구현: 간단한 <script> 태그를 로드하는 JavaScript 스니펫. 가장 간단한 형태:
<script src="https://platform.example.com/widget.js" data-artist="alice"></script>
위젯은 다음을 수행합니다:
- DOMContentLoaded 또는 스크립트가 비동기로 로드될 때까지 기다린 후에 페이지에 주문 양식을 삽입합니다.
- iframe을 사용하여 상위 페이지 스타일과 격리하거나 또는 섀도우 DOM에 렌더링합니다.
- 백엔드와 통신하기 위해 공개 API를 호출합니다.
아키텍처 고려 사항:
- CORS: 백엔드는
Access-Control-Allow-Origin: *또는 특정 출처를 허용해야 합니다. - 보안: 위젯은 신뢰할 수 없는 사이트에 삽입될 수 있으므로 CSRF 보호 및 속도 제한이 중요합니다.
- 성능: 위젯 스크립트는 작고(10-20KB minified+gzip) 동기적으로 로드되어서는 안 됩니다.
레이어 3: 맞춤 페이지 호스팅
대상: 자신의 사이트를 완전히 제어하고 싶지만 플랫폼의 주문 처리 및 결제 인프라가 필요한 크리에이터.
구현: 업로드된 정적 파일을 제공합니다.
uploads/
artists/
alice/
index.html
style.css
script.js
assets/
logo.png
라우팅:
// /artist/:subdomain에 대한 모든 요청은 먼저 맞춤 파일을 확인합니다.
app.get('/artist/:subdomain', async (req, res) => {
const customPath = path.join(UPLOAD_DIR, req.params.subdomain, 'index.html')
if (fs.existsSync(customPath)) {
return res.sendFile(customPath)
}
// 레이어 1 또는 2로 폴백
next()
})
제한 사항 및 보안:
- 업로드된 파일은 정적 에셋만 허용됩니다(서버 측 실행 금지).
- 파일 크기 제한(예: 총 50MB).
- Sanitize HTML(악성 스크립트 방지를 위해 선택적 CSP 헤더가 좋은 추가 사항).
- 맞춤 페이지는 공개 API를 통해 주문 데이터에 접근할 수 있습니다.
아키텍처 요약
3계층 시스템은 확장성을 위한 명확한 분리를 제공합니다:
- 내장 템플릿: 낮은 진입 장벽, 플랫폼에서 완전히 호스팅.
- 임베드 가능 위젯: 유연성과 통합 제어 사이의 균형.
- 맞춤 페이지 호스팅: 최대 유연성, 개발 노력 필요.
각 계층은 동일한 백엔드 API와 인프라를 공유하므로 일관된 데이터 모델과 비즈니스 로직을 유지하면서 다양한 사용자 요구를 충족합니다.