feat: 프로젝트 API Bearer 토큰 인증 + E2E 테스트 스크립트 + 크몽 마케팅 이미지

- app/api/projects, link/route: Cookie + Bearer 토큰 이중 인증 지원 (E2E 테스트 대응)
- app/mypage: 로또 기록 탭 제거, 구독 빈 상태 프롬프트 서비스로 변경
- scripts/test-flow.mjs: 견적서 발송→연결→마일스톤 진행 E2E 테스트 스크립트
- supabase/migrations/003: quotes RLS 비활성화 (관리자 서버 전용 접근)
- marketing/kmong-images: 크몽 서비스 A 상세 이미지 5장 (HTML 스크린샷용)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 04:15:47 +09:00
parent 19b09e3b90
commit 2c9af41631
10 changed files with 1589 additions and 77 deletions

View File

@@ -1,12 +1,29 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { createClient } from '@/lib/supabase/server';
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// Cookie 기반 또는 Bearer 토큰 기반 인증 모두 지원
const authHeader = request.headers.get('authorization');
let user = null;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const client = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
const { data } = await client.auth.getUser(token);
user = data?.user ?? null;
} else {
const supabase = await createClient();
const { data } = await supabase.auth.getUser();
user = data?.user ?? null;
}
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();

View File

@@ -1,12 +1,28 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { createClient } from '@/lib/supabase/server';
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
export const runtime = 'nodejs';
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
export async function GET(request: Request) {
// Cookie 기반 또는 Bearer 토큰 기반 인증 모두 지원
const authHeader = request.headers.get('authorization');
let user = null;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const client = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
const { data } = await client.auth.getUser(token);
user = data?.user ?? null;
} else {
const supabase = await createClient();
const { data } = await supabase.auth.getUser();
user = data?.user ?? null;
}
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const admin = createAdminClient();

View File

@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
return url;
}
type Tab = 'profile' | 'projects' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders';
type Tab = 'profile' | 'projects' | 'subscription' | 'saju' | 'payments' | 'orders';
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
interface SajuRecord {
@@ -311,7 +311,6 @@ export default function MyPage() {
{ key: 'profile', label: '내 정보' },
{ key: 'projects', label: '프로젝트 현황', count: projects.length || undefined },
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
{ key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined },
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
@@ -546,17 +545,6 @@ export default function MyPage() {
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/services/lotto/recommend" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-amber-300 hover:bg-amber-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -580,9 +568,9 @@ export default function MyPage() {
<EmptyState
icon="📦"
title="활성 구독이 없습니다"
desc="로또 번호 추천 서비스를 구독하면 여기서 관리할 수 있습니다"
linkHref="/services/lotto"
linkLabel="구독 플랜 보기"
desc="구독 중인 서비스가 없습니다"
linkHref="/services/prompt"
linkLabel="서비스 둘러보기"
/>
) : (
activeSubscriptions.map((sub) => {
@@ -662,9 +650,9 @@ export default function MyPage() {
{/* 액션 버튼 */}
<div className="flex gap-2 flex-wrap">
<a href="/services/lotto/recommend"
className="flex-1 text-center py-2 text-sm font-bold text-white bg-amber-500 hover:bg-amber-400 rounded-xl transition shadow-sm">
<a href="/freelance"
className="flex-1 text-center py-2 text-sm font-bold text-white bg-[#1a56db] hover:bg-blue-700 rounded-xl transition shadow-sm">
</a>
{isActive && (
<button
@@ -680,65 +668,15 @@ export default function MyPage() {
})
)}
{/* 구독 플랜 이동 */}
{/* 서비스 이동 */}
<div className="text-center py-2">
<a href="/services/lotto" className="text-sm text-slate-400 hover:text-slate-600 transition">
<a href="/services/prompt" className="text-sm text-slate-400 hover:text-slate-600 transition">
</a>
</div>
</div>
)}
{/* 로또 번호 기록 */}
{tab === 'lotto' && (
<div>
{lottoHistory.length === 0 ? (
<EmptyState
icon="🎰"
title="생성된 번호 기록이 없습니다"
desc="로또 번호 추천 페이지에서 번호를 생성하면 여기에 기록됩니다"
linkHref="/services/lotto/recommend"
linkLabel="번호 추천받기"
/>
) : (
<div className="space-y-3">
<div className="text-xs text-slate-400 mb-1"> {lottoHistory.length} </div>
{lottoHistory.map((item) => {
const info = PLAN_LABELS[item.plan_id];
return (
<div key={item.id} className="bg-white rounded-2xl border border-[#dbe8ff] px-5 py-4 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex gap-1.5 flex-wrap">
{item.numbers.map((n) => {
const color =
n <= 10 ? 'bg-yellow-400 text-yellow-900' :
n <= 20 ? 'bg-blue-500 text-white' :
n <= 30 ? 'bg-red-500 text-white' :
n <= 40 ? 'bg-slate-500 text-white' :
'bg-green-500 text-white';
return (
<span key={n} className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-xs font-black shadow-sm`}>
{n}
</span>
);
})}
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${item.source === 'nas' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-slate-100 text-slate-500'}`}>
{item.source === 'nas' ? 'NAS 추천' : '로컬 생성'}
</span>
<span className="text-xs text-amber-600 font-semibold">{info?.emoji} {info?.label}</span>
<span className="text-xs text-slate-400">{new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* 사주 기록 */}
{tab === 'saju' && (
<div>