#!/bin/bash
################################################################################
# 삼生調和 타로카드 – Cloudflare Dev 도메인 최종 배포 패키지
# 프로젝트명: samsa-harmony-cloudflare-complete
# 생성일시: $(date)
# 목표: 단일 zip 파일로 Cloudflare Pages & Workers 즉시 배포
################################################################################
set -e
# 색상 코드 (로그 강조)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 변수 설정
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
PROJECT_NAME="samsa-harmony-cloudflare-complete"
FINAL_ZIP="${PROJECT_NAME}-${TIMESTAMP}.zip"
STAGING_DIR="/tmp/samsa-harmony-cf-${TIMESTAMP}"
CURRENT_DIR=$(pwd)
echo -e "${BLUE}════════════════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} 삼生調和 타로카드 – Cloudflare Dev 배포 패키지 생성${NC}"
echo -e "${BLUE}════════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW}타임스탬프: ${TIMESTAMP}${NC}"
echo -e "${YELLOW}스테이징 경로: ${STAGING_DIR}${NC}"
echo -e "${YELLOW}최종 ZIP: ${FINAL_ZIP}${NC}"
echo ""
# Step 1: 스테이징 디렉터리 생성
echo -e "${GREEN}[Step 1] 스테이징 디렉터리 생성...${NC}"
mkdir -p "${STAGING_DIR}"/{web,api,database,docs,scripts,config}
echo -e "${GREEN}✓ 디렉터리 생성 완료${NC}"
echo ""
# ============================================================================
# Step 2: WEB 애플리케이션 (React + Vite)
# ============================================================================
echo -e "${GREEN}[Step 2] Web 애플리케이션 파일 생성...${NC}"
# web/package.json
cat > "${STAGING_DIR}/web/package.json" << 'EOF'
{
"name": "samsa-harmony-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"deploy": "wrangler pages publish dist --project-name=samsa-harmony"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
}
}
EOF
# web/vite.config.js
cat > "${STAGING_DIR}/web/vite.config.js" << 'EOF'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'https://api.samsa-harmony.dev',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
minify: 'terser',
},
});
EOF
# web/.env.production
cat > "${STAGING_DIR}/web/.env.production" << 'EOF'
VITE_API_URL=https://api.samsa-harmony.dev
VITE_ENV=production
EOF
# web/index.html
cat > "${STAGING_DIR}/web/index.html" << 'EOF'
삼生調和 타로카드 | Samsa Harmony Tarot
EOF
# web/src/main.jsx
mkdir -p "${STAGING_DIR}/web/src"
cat > "${STAGING_DIR}/web/src/main.jsx" << 'EOF'
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
,
);
EOF
# web/src/App.jsx
cat > "${STAGING_DIR}/web/src/App.jsx" << 'EOF'
import React, { useState } from 'react';
import axios from 'axios';
import './App.css';
const API_URL = import.meta.env.VITE_API_URL || 'https://api.samsa-harmony.dev';
export default function App() {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
name: '',
gender: 'M',
birthDate: '',
birthTime: '12:00',
});
const [saju, setSaju] = useState(null);
const [prayerForce, setPrayerForce] = useState(null);
const [cards, setCards] = useState(null);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setError(null);
};
const calculateSaju = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.post(`${API_URL}/api/saju/calculate`, {
name: formData.name,
birthDate: formData.birthDate,
birthTime: formData.birthTime,
gender: formData.gender,
});
setSaju(response.data);
setStep(2);
} catch (err) {
setError(`사주 계산 오류: ${err.message}`);
} finally {
setLoading(false);
}
};
const calculatePrayer = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.post(`${API_URL}/api/prayer/calculate`, {
name: formData.name,
birthDate: formData.birthDate,
gender: formData.gender,
});
setPrayerForce(response.data);
// 자동으로 카드 선택
const cardsResponse = await axios.post(`${API_URL}/api/cards/select`, {
prayerForce: response.data.force,
prayerGrade: response.data.grade,
});
setCards(cardsResponse.data);
setStep(3);
} catch (err) {
setError(`기도력 계산 오류: ${err.message}`);
} finally {
setLoading(false);
}
};
const downloadPPT = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.post(`${API_URL}/api/ppt/generate`, {
name: formData.name,
saju,
prayerForce,
cards,
}, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${formData.name}_타로결과.pptx`);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
} catch (err) {
setError(`PPT 생성 오류: ${err.message}`);
} finally {
setLoading(false);
}
};
return (
{error &&
{error}
}
{step === 1 && (
)}
{step === 2 && saju && (
Step 2: 기도력 입력
이름: {formData.name}
사주: {saju.fourPillars}
일간: {saju.dayMaster}
)}
{step === 3 && cards && prayerForce && (
Step 3: 결과 및 카드
기도력: {prayerForce.force.toFixed(2)}
등급: {prayerForce.grade}
선택된 카드 (7장)
{cards.map((card, idx) => (
- {idx + 1}. {card.cardName}
))}
)}
);
}
EOF
# web/src/App.css
cat > "${STAGING_DIR}/web/src/App.css" << 'EOF'
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 40px;
border-bottom: 3px solid #2a5298;
padding-bottom: 20px;
}
.header h1 {
font-size: 2.5em;
color: #1e3c72;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
color: #666;
}
.step-section {
margin: 30px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.step-section h2 {
color: #2a5298;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group label {
font-weight: bold;
color: #333;
}
.form-group input,
.form-group select {
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 1em;
font-family: 'Noto Sans KR', sans-serif;
}
.form-group button,
button {
padding: 12px 24px;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: white;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.form-group button:hover,
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(42, 82, 152, 0.4);
}
.form-group button:disabled,
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.saju-display,
.result-display {
background: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.saju-display p,
.result-display p {
margin: 10px 0;
font-size: 1em;
}
.cards-list {
list-style: none;
padding-left: 0;
}
.cards-list li {
padding: 10px;
margin: 8px 0;
background: #e8f4f8;
border-left: 4px solid #2a5298;
border-radius: 3px;
}
.error-banner {
background: #ffe5e5;
color: #c33;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border-left: 4px solid #c33;
}
.footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 0.9em;
}
@media (max-width: 600px) {
.container {
padding: 15px;
}
.header h1 {
font-size: 1.8em;
}
}
EOF
# web/src/index.css
cat > "${STAGING_DIR}/web/src/index.css" << 'EOF'
:root {
--primary-color: #1e3c72;
--secondary-color: #2a5298;
--text-color: #333;
--light-bg: #f9f9f9;
--border-color: #ddd;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: var(--text-color);
line-height: 1.6;
}
#root {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
EOF
# web/.gitignore
cat > "${STAGING_DIR}/web/.gitignore" << 'EOF'
node_modules/
dist/
.env
.env.local
.env.*.local
*.log
.DS_Store
.vscode/
.idea/
EOF
echo -e "${GREEN}✓ Web 애플리케이션 파일 생성 완료${NC}"
echo ""
# ============================================================================
# Step 3: API 서버 (Cloudflare Workers)
# ============================================================================
echo -e "${GREEN}[Step 3] API 서버 (Cloudflare Workers) 파일 생성...${NC}"
# api/wrangler.toml
cat > "${STAGING_DIR}/api/wrangler.toml" << 'EOF'
name = "samsa-harmony-api"
type = "javascript"
compatibility_date = "2024-03-30"
account_id = "your_cloudflare_account_id"
workers_dev = true
[env.production]
name = "samsa-harmony-api-prod"
routes = [
{ pattern = "api.samsa-harmony.dev/*", zone_id = "your_zone_id" }
]
[build]
command = "npm run build"
[build.upload]
format = "modules"
main = "./dist/index.js"
[env.production.vars]
ENVIRONMENT = "production"
API_URL = "https://api.samsa-harmony.dev"
CORS_ORIGIN = "https://samsa-harmony.dev"
[env.development.vars]
ENVIRONMENT = "development"
API_URL = "http://localhost:8787"
CORS_ORIGIN = "*"
EOF
# api/package.json
cat > "${STAGING_DIR}/api/package.json" << 'EOF'
{
"name": "samsa-harmony-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"build": "node ./build.js",
"deploy": "wrangler publish",
"deploy:prod": "wrangler publish --env production"
},
"dependencies": {
"itty-router": "^4.0.0"
},
"devDependencies": {
"wrangler": "^3.36.0",
"esbuild": "^0.20.0"
}
}
EOF
# api/build.js
cat > "${STAGING_DIR}/api/build.js" << 'EOF'
import esbuild from 'esbuild';
import fs from 'fs';
const distDir = './dist';
if (!fs.existsSync(distDir)) fs.mkdirSync(distDir);
esbuild.build({
entryPoints: ['./src/index.js'],
bundle: true,
outfile: './dist/index.js',
format: 'modules',
target: 'esnext',
platform: 'browser',
minify: true,
}).catch(() => process.exit(1));
EOF
# api/src/index.js
mkdir -p "${STAGING_DIR}/api/src"
cat > "${STAGING_DIR}/api/src/index.js" << 'EOF'
import { Router } from 'itty-router';
const router = Router();
// CORS 헤더
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// 헬퍼 함수
const jsonResponse = (data, status = 200) => {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
};
const errorResponse = (message, status = 400) => {
return jsonResponse({ error: message }, status);
};
// OPTIONS 요청 처리
router.options('*', () => new Response(null, { headers: corsHeaders }));
// 1. 사주 계산
router.post('/api/saju/calculate', async (request) => {
try {
const { name, birthDate, birthTime, gender } = await request.json();
if (!birthDate || !birthTime) {
return errorResponse('생년월일과 출생시간은 필수입니다.');
}
const fourPillars = `${birthDate}T${birthTime}`;
const dayMaster = '丙火'; // 예시 (실제로는 복잡한 계산)
return jsonResponse({
name,
birthDate,
birthTime,
gender,
fourPillars,
dayMaster,
analysis: '사주 분석 내용이 여기에 표시됩니다.',
});
} catch (error) {
return errorResponse(`사주 계산 오류: ${error.message}`);
}
});
// 2. 기도력 계산
router.post('/api/prayer/calculate', async (request) => {
try {
const { name, birthDate, gender } = await request.json();
if (!birthDate) {
return errorResponse('생년월일은 필수입니다.');
}
// 기도력 계산 (0~100 범위)
const force = Math.random() * 100;
const gradeMap = [
{ min: 90, max: 100, grade: 'S' },
{ min: 80, max: 89, grade: 'A' },
{ min: 70, max: 79, grade: 'B' },
{ min: 60, max: 69, grade: 'C' },
{ min: 40, max: 59, grade: 'D' },
{ min: 0, max: 39, grade: 'F' },
];
const grade = gradeMap.find(g => force >= g.min && force <= g.max)?.grade || 'F';
return jsonResponse({
name,
force: force.toFixed(2),
grade,
breakdown: {
discipline: (Math.random() * 20).toFixed(2),
concentration: (Math.random() * 20).toFixed(2),
compassion: (Math.random() * 20).toFixed(2),
wisdom: (Math.random() * 20).toFixed(2),
courage: (Math.random() * 20).toFixed(2),
},
});
} catch (error) {
return errorResponse(`기도력 계산 오류: ${error.message}`);
}
});
// 3. 카드 선택
router.post('/api/cards/select', async (request) => {
try {
const { prayerForce, prayerGrade } = await request.json();
const allCards = [
{ cardName: '우주의 깨달음 (The Fool)', category: 'Major Arcana' },
{ cardName: '마그의 손 (The Magician)', category: 'Major Arcana' },
{ cardName: '여사제의 지혜 (The Priestess)', category: 'Major Arcana' },
{ cardName: '창조의 황제녀 (The Empress)', category: 'Major Arcana' },
{ cardName: '권력의 황제 (The Emperor)', category: 'Major Arcana' },
{ cardName: '영혼의 유대 (The Hierophant)', category: 'Major Arcana' },
{ cardName: '마음의 연 (The Lovers)', category: 'Major Arcana' },
{ cardName: '생명의 수레 (The Chariot)', category: 'Major Arcana' },
{ cardName: '강인함 (Strength)', category: 'Major Arcana' },
{ cardName: '침묵의 은자 (The Hermit)', category: 'Major Arcana' },
{ cardName: '운명의 수레바퀴 (Wheel of Fortune)', category: 'Major Arcana' },
{ cardName: '정의 (Justice)', category: 'Major Arcana' },
{ cardName: '희생 (The Hanged Man)', category: 'Major Arcana' },
{ cardName: '변화 (Death)', category: 'Major Arcana' },
{ cardName: '절제 (Temperance)', category: 'Major Arcana' },
{ cardName: '어둠의 군주 (The Devil)', category: 'Major Arcana' },
{ cardName: '마음의 탑 (The Tower)', category: 'Major Arcana' },
{ cardName: '희망의 별 (The Star)', category: 'Major Arcana' },
{ cardName: '꿈의 달 (The Moon)', category: 'Major Arcana' },
{ cardName: '빛의 태양 (The Sun)', category: 'Major Arcana' },
{ cardName: '판단 (Judgement)', category: 'Major Arcana' },
{ cardName: '완성의 세계 (The World)', category: 'Major Arcana' },
{ cardName: '식신 (食神)', category: 'TenStar' },
{ cardName: '상관 (傷官)', category: 'TenStar' },
{ cardName: '정재 (正財)', category: 'TenStar' },
{ cardName: '편재 (偏財)', category: 'TenStar' },
{ cardName: '정관 (正官)', category: 'TenStar' },
{ cardName: '편관 (偏官)', category: 'TenStar' },
{ cardName: '정인 (正印)', category: 'TenStar' },
{ cardName: '편인 (偏印)', category: 'TenStar' },
{ cardName: '비견 (比肩)', category: 'TenStar' },
{ cardName: '겁재 (劫財)', category: 'TenStar' },
];
// 7장 랜덤 선택
const selected = [];
const shuffled = allCards.sort(() => Math.random() - 0.5);
for (let i = 0; i < 7; i++) {
selected.push(shuffled[i]);
}
return jsonResponse({
count: 7,
prayerForce: parseFloat(prayerForce),
grade: prayerGrade,
cards: selected,
});
} catch (error) {
return errorResponse(`카드 선택 오류: ${error.message}`);
}
});
// 4. PPT 생성
router.post('/api/ppt/generate', async (request) => {
try {
const { name, saju, prayerForce, cards } = await request.json();
const filename = `${name || '사용자'}_타로결과_${Date.now()}.pptx`;
return jsonResponse({
success: true,
filename,
downloadUrl: `/downloads/${filename}`,
message: 'PPT가 생성되었습니다. (실제 PPT 생성은 Backend 필요)',
});
} catch (error) {
return errorResponse(`PPT 생성 오류: ${error.message}`);
}
});
// 5. 건강 체크
router.get('/api/health', () => {
return jsonResponse({
status: 'OK',
timestamp: new Date().toISOString(),
environment: 'production',
});
});
// 404 처리
router.all('*', () => {
return errorResponse('엔드포인트를 찾을 수 없습니다.', 404);
});
// Cloudflare Workers 진입점
export default {
fetch: (request) => router.handle(request),
};
EOF
# api/.gitignore
cat > "${STAGING_DIR}/api/.gitignore" << 'EOF'
node_modules/
dist/
.env
.env.local
.env.*.local
*.log
.DS_Store
.wrangler/
EOF
echo -e "${GREEN}✓ API 서버 파일 생성 완료${NC}"
echo ""
# ============================================================================
# Step 4: 문서 및 설정 파일
# ============================================================================
echo -e "${GREEN}[Step 4] 문서 및 설정 파일 생성...${NC}"
# README.md
cat > "${STAGING_DIR}/README.md" << 'EOF'
# 🔮 삼生調和 타로카드 (Samsa Harmony Tarot)
불교 기도력 · 사주 · 타로 통합 분석 시스템
## 📋 프로젝트 개요
- **프로젝트명**: 삼生調和 타로카드
- **개발사**: 무진장 통합수행원
- **배포 플랫폼**: Cloudflare Pages (웹) + Cloudflare Workers (API)
- **도메인**: `samsa-harmony.dev` (개발), `api.samsa-harmony.dev` (API)
- **목표**: 웹·모바일 동시 출시 (2026년)
## 🛠 기술 스택
| 계층 | 기술 |
|------|------|
| 프론트엔드 | React 18 + Vite 5 + Axios |
| API | Cloudflare Workers + itty-router |
| 호스팅 | Cloudflare Pages (웹) + Workers (서버리스) |
| 개발 | Node.js 18+, npm/yarn |
## 📦 프로젝트 구조