feat(downloads): 다운로드 검증을 orders 단일 소스로 교체 + 내 제품 제품별 그룹핑
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user