feat(downloads): 다운로드 검증을 orders 단일 소스로 교체 + 내 제품 제품별 그룹핑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:47:32 +09:00
parent 3fa865a6e7
commit f151af89f3
3 changed files with 126 additions and 130 deletions

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr'; import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin'; import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets'; import { getUserAccessibleProductIds, getFilesByProductIds } from '@/lib/supabase/product-files';
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -20,21 +19,25 @@ export async function GET() {
}, },
); );
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ files: [] }); if (!user) return NextResponse.json({ products: [] });
const admin = createAdminClient(); const admin = createAdminClient();
const { data: orders } = await admin const productIds = await getUserAccessibleProductIds(admin, user.id);
.from('contact_requests') if (productIds.length === 0) return NextResponse.json({ products: [] });
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const tiers = new Set<PackTier>(); const [files, { data: products }] = await Promise.all([
for (const o of (orders ?? [])) { getFilesByProductIds(admin, productIds),
const t = extractPackTier(o.service); admin.from('products').select('id, name').in('id', productIds),
if (t) tierIncludes(t).forEach((x) => tiers.add(x)); ]);
const nameMap = new Map((products ?? []).map((p) => [p.id, p.name as string]));
const grouped = new Map<string, { id: string; name: string; files: typeof files }>();
for (const f of files) {
if (!f.product_id) continue;
if (!grouped.has(f.product_id)) {
grouped.set(f.product_id, { id: f.product_id, name: nameMap.get(f.product_id) ?? f.product_id, files: [] });
}
grouped.get(f.product_id)!.files.push(f);
} }
return NextResponse.json({ products: Array.from(grouped.values()) });
const files = await getPackFilesForTiers(admin, Array.from(tiers));
return NextResponse.json({ files });
} }

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr'; import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin'; import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets'; import { getUserAccessibleProductIds, getFileById } from '@/lib/supabase/product-files';
import { tierIncludes, getPackFileById } from '@/lib/supabase/pack-files';
import { signLink } from '@/lib/web-backend'; import { signLink } from '@/lib/web-backend';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -33,33 +32,18 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// 2) orders 조회 — completed Music 팩 구매 확인 // 2) orders(paid) 단일 소스로 접근 가능한 product_id 확인
const admin = createAdminClient(); const admin = createAdminClient();
const { data: orders } = await admin const accessible = await getUserAccessibleProductIds(admin, user.id);
.from('contact_requests') if (accessible.length === 0) {
.select('service, status') return NextResponse.json({ error: '구매 내역이 없거나 입금 확인 전입니다' }, { status: 403 });
.eq('user_id', user.id)
.eq('status', 'completed');
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
} }
if (tiers.size === 0) { const file = await getFileById(admin, fileId);
return NextResponse.json({ error: '구매 내역이 없거나 결제 미완료입니다' }, { status: 403 }); if (!file || file.deleted_at || !file.product_id || !accessible.includes(file.product_id)) {
return NextResponse.json({ error: '구매한 제품의 파일이 아닙니다' }, { status: 403 });
} }
// 3) 파일 조회 + tier 매칭 // 3) web-backend 호출 → DSM 공유 링크
const file = await getPackFileById(admin, fileId);
if (!file) {
return NextResponse.json({ error: '파일을 찾을 수 없습니다' }, { status: 404 });
}
if (!tiers.has(file.min_tier)) {
return NextResponse.json({ error: '구매 등급에서 접근할 수 없는 파일입니다' }, { status: 403 });
}
// 4) web-backend 호출 → DSM 공유 링크
try { try {
const { url, expires_at } = await signLink({ const { url, expires_at } = await signLink({
file_path: file.file_path, file_path: file.file_path,

View File

@@ -6,8 +6,6 @@ import Link from 'next/link';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal'; import TelegramGuideModal from '@/app/components/TelegramGuideModal';
import { PACK_TIER_NAMES, extractPackTier, type PackTier } from '@/lib/pack-assets';
import type { PackFile } from '@/lib/supabase/pack-files';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact'; import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역). // 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
@@ -57,6 +55,25 @@ interface Order {
status: string; status: string;
} }
// 구매 제품 자료 그룹 (/api/packs/list-mine 응답)
interface ProductFileItem {
id: string;
label: string;
}
interface ProductGroup {
id: string;
name: string;
files: ProductFileItem[];
}
// orders 테이블(결제 단일 소스) — pending 안내용
interface ProductOrder {
id: string;
product_id: string | null;
status: string;
created_at: string;
}
function MyPageContent() { function MyPageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -66,7 +83,8 @@ function MyPageContent() {
const [tab, setTab] = useState<Tab>(() => resolveTab(searchParams.get('tab'))); const [tab, setTab] = useState<Tab>(() => resolveTab(searchParams.get('tab')));
const [payments, setPayments] = useState<Payment[]>([]); const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]); const [orders, setOrders] = useState<Order[]>([]);
const [packFiles, setPackFiles] = useState<PackFile[]>([]); const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
const [downloading, setDownloading] = useState<string | null>(null); const [downloading, setDownloading] = useState<string | null>(null);
// 텔레그램 연동 상태 // 텔레그램 연동 상태
@@ -111,13 +129,22 @@ function MyPageContent() {
.maybeSingle(); .maybeSingle();
setTelegramChatId(profile?.telegram_chat_id ?? null); setTelegramChatId(profile?.telegram_chat_id ?? null);
// 구매한 팩 자료 파일 조회 // 구매 제품 자료 그룹 조회 (orders paid 단일 소스)
const filesRes = await fetch('/api/packs/list-mine'); const filesRes = await fetch('/api/packs/list-mine');
if (filesRes.ok) { if (filesRes.ok) {
const { files } = await filesRes.json(); const { products } = await filesRes.json();
setPackFiles(files ?? []); setProductGroups(products ?? []);
} }
// 결제 주문(orders 테이블) 조회 — pending 안내 / 주문 내역 공유
const { data: prodOrders } = await supabase
.from('orders')
.select('id, product_id, status, created_at')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(50);
setProductOrders(prodOrders || []);
setLoading(false); setLoading(false);
} }
init(); init();
@@ -204,15 +231,13 @@ function MyPageContent() {
if (!user) return null; if (!user) return null;
// contact_requests 중 팩 주문만 추려 '내 제품' 탭에서 다운로드 노출 // 입금 확인 대기 중인 주문 (orders 테이블 pending)
const packOrders = orders const pendingOrders = productOrders.filter((o) => o.status === 'pending');
.map((o) => ({ order: o, tier: extractPackTier(o.service) }))
.filter((x): x is { order: Order; tier: PackTier } => x.tier !== null);
const tabs: { key: Tab; label: string; count?: number }[] = [ const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '프로필' }, { key: 'profile', label: '프로필' },
{ key: 'requests', label: '내 의뢰', count: orders.length || undefined }, { key: 'requests', label: '내 의뢰', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: packOrders.length || undefined }, { key: 'products', label: '내 제품', count: productGroups.length || undefined },
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined }, { key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
]; ];
@@ -501,105 +526,89 @@ function MyPageContent() {
</div> </div>
)} )}
{/* ===== 내 제품 (구매한 ) ===== */} {/* ===== 내 제품 (구매한 제품 자료) ===== */}
{tab === 'products' && ( {tab === 'products' && (
<div className="space-y-4"> <div className="space-y-4">
{packOrders.length === 0 ? ( {/* 입금 확인 대기 안내 */}
{pendingOrders.length > 0 && (
<div
className="rounded-xl px-4 py-3 border flex items-start gap-3"
style={{ background: 'var(--jsm-accent-soft)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0 mt-0.5"
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-accent)' }}
>
</span>
<div className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{pendingOrders.length} . .
{' '}
<a
href={KAKAO_OPENCHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="font-semibold hover:underline"
style={{ color: 'var(--jsm-accent)' }}
>
</a>
</div>
</div>
)}
{productGroups.length === 0 ? (
<EmptyState <EmptyState
title="구매한 제품이 없습니다" title="구매한 제품이 없습니다"
desc="음악 팩을 구매하시면 자료를 여기서 다운로드할 수 있습니다." desc="소프트웨어·자료를 구매하시면 여기서 다운로드할 수 있습니다."
linkHref="/music/packs" linkHref="/products"
linkLabel="음악 팩 보기" linkLabel="소프트웨어 보기"
/> />
) : ( ) : (
packOrders.map(({ order, tier }) => { productGroups.map((group) => (
const completed = order.status === 'completed'; <Card key={group.id}>
const filesForTier = packFiles.filter((pf) => { <div className="flex items-start justify-between gap-3 mb-4">
if (tier === 'starter') return pf.min_tier === 'starter'; <div className="font-bold text-base break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
if (tier === 'pro') return pf.min_tier === 'starter' || pf.min_tier === 'pro'; {group.name}
return true; // master </div>
}); </div>
return ( <div className="border-t pt-4" style={{ borderColor: 'var(--jsm-line)' }}>
<Card key={order.id}> <div className="text-sm font-semibold mb-3" style={{ color: 'var(--jsm-ink)' }}>
<div className="flex items-start justify-between gap-3 mb-4"> ({group.files.length})
<div>
<div className="font-bold text-base break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{PACK_TIER_NAMES[tier]}
</div>
<div className="text-xs mt-1" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(order.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
<StatusBadge status={order.status} />
</div> </div>
<div className="border-t pt-4" style={{ borderColor: 'var(--jsm-line)' }}> {group.files.length === 0 ? (
<div className="text-sm font-semibold mb-3" style={{ color: 'var(--jsm-ink)' }}> <p className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
({filesForTier.length}) . 1:1로 .
</div> </p>
) : (
{filesForTier.length === 0 ? ( <>
<p className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
. 1:1로 .
</p>
) : (
<ul className="space-y-2 mb-3"> <ul className="space-y-2 mb-3">
{filesForTier.map((f) => ( {group.files.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-2 text-sm"> <li key={f.id} className="flex items-center justify-between gap-2 text-sm">
<span className="flex-1 break-keep" style={{ color: 'var(--jsm-ink)' }}> <span className="flex-1 break-keep" style={{ color: 'var(--jsm-ink)' }}>
{f.label} {f.label}
</span> </span>
{completed ? ( <button
<button onClick={() => handleDownload(f.id)}
onClick={() => handleDownload(f.id)} disabled={downloading === f.id}
disabled={downloading === f.id} className="px-3 py-1.5 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-50"
className="px-3 py-1.5 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-50" style={{ background: 'var(--jsm-accent)' }}
style={{ background: 'var(--jsm-accent)' }} >
> {downloading === f.id ? '준비중...' : '다운로드'}
{downloading === f.id ? '준비중...' : '다운로드'} </button>
</button>
) : (
<span className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
</span>
)}
</li> </li>
))} ))}
</ul> </ul>
)}
{completed && filesForTier.length > 0 && (
<p className="text-xs leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}> <p className="text-xs leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
4 . 4 .
</p> </p>
)} </>
)}
{!completed && ( </div>
<div </Card>
className="rounded-lg px-3 py-2.5 text-xs leading-relaxed text-center" ))
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
.
{order.status === 'in_progress'
? ' 결제 처리 중입니다.'
: ' 입금 안내는 카톡 1:1로 드립니다.'}
<br />
<a
href={KAKAO_OPENCHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="font-semibold hover:underline"
style={{ color: 'var(--jsm-accent)' }}
>
</a>
</div>
)}
</div>
</Card>
);
})
)} )}
</div> </div>
)} )}