From 42df73d0cdd365daddfaf76051282d988f19094c Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:01:31 +0900 Subject: [PATCH 1/3] feat: render public SEO pages and item permalinks --- docs/search-seo.md | 35 +++ next.config.ts | 2 + package-lock.json | 4 +- package.json | 2 +- .../auction-history/AuctionHistoryClient.tsx | 32 ++- .../[auctionBuyId]/page.test.tsx | 32 +++ .../auction-history/[auctionBuyId]/page.tsx | 21 +- .../(main)/auction-history/page.seo.test.tsx | 10 +- src/app/(main)/auction-history/page.tsx | 4 + .../AuctionRealtimeClient.tsx | 32 ++- .../[auctionId]/page.test.tsx | 32 +++ .../auction-realtime/[auctionId]/page.tsx | 21 +- src/app/(main)/community/CommunityClient.tsx | 23 +- src/app/(main)/community/[id]/page.test.tsx | 47 ++-- src/app/(main)/community/[id]/page.tsx | 34 ++- src/app/(main)/community/page.test.tsx | 77 ++++++ src/app/(main)/community/page.tsx | 66 ++++- src/app/(main)/items/[itemKey]/page.test.tsx | 98 ++++++++ src/app/(main)/items/[itemKey]/page.tsx | 166 +++++++++++++ src/app/(main)/layout.tsx | 2 +- src/app/(main)/outfits/[id]/page.tsx | 46 +++- src/app/(main)/outfits/page.test.tsx | 61 +++++ src/app/(main)/outfits/page.tsx | 70 +++++- src/app/robots.test.ts | 2 +- src/app/robots.ts | 2 +- src/app/sitemap-items.xml/route.ts | 6 + src/app/sitemap-outfits.xml/route.ts | 6 + src/app/sitemap-posts.xml/route.ts | 6 + src/app/sitemap-static.xml/route.ts | 6 + src/app/sitemap.test.ts | 176 +++++++++++++- src/app/sitemap.ts | 9 - src/app/sitemap.xml/route.ts | 6 + .../page/auction-common/AuctionDetailView.tsx | 36 +-- .../auction-common/AuctionMarketSummary.tsx | 77 ++++++ .../page/auction-common/AuctionPageHeader.tsx | 4 +- src/components/page/community/PostCard.tsx | 14 +- .../page/community/PostList.test.tsx | 110 +++++++++ src/components/page/community/PostList.tsx | 53 +++- .../page/item-info/ItemInfoTable.test.tsx | 16 +- .../page/item-info/ItemInfoTable.tsx | 31 ++- src/components/page/items/ItemMetadata.tsx | 42 ++++ .../page/items/ItemPriceHistory.test.tsx | 77 ++++++ .../page/items/ItemPriceHistory.tsx | 157 ++++++++++++ .../page/outfits/OutfitDetail.test.tsx | 2 +- src/components/page/outfits/OutfitDetail.tsx | 21 +- .../page/outfits/OutfitFeed.test.tsx | 2 + src/components/page/outfits/OutfitFeed.tsx | 63 ++++- src/components/seo/HomeLanding.test.tsx | 33 +-- src/components/seo/PopularItemLinks.test.tsx | 38 +++ src/components/seo/PopularItemLinks.tsx | 48 ++-- src/hooks/useInfinitePosts.ts | 142 ++++------- src/lib/auction/detail.ts | 210 +++------------- src/lib/community/feed.ts | 87 +++++++ src/lib/community/public-feed.test.ts | 60 +++++ src/lib/community/public-feed.ts | 38 +++ src/lib/items/data.test.ts | 59 +++++ src/lib/items/data.ts | 180 ++++++++++++++ src/lib/items/dates.ts | 21 ++ src/lib/items/identity.test.ts | 43 ++++ src/lib/items/identity.ts | 62 +++++ src/lib/outfits/public-feed.ts | 25 ++ src/lib/outfits/public-outfit.ts | 4 +- src/lib/outfits/seo.test.ts | 31 +++ src/lib/outfits/seo.ts | 21 ++ src/lib/seo/public-api.ts | 24 ++ src/lib/seo/sitemaps.ts | 229 ++++++++++++++++++ src/lib/seo/structured-data.ts | 20 ++ 67 files changed, 2722 insertions(+), 494 deletions(-) create mode 100644 docs/search-seo.md create mode 100644 src/app/(main)/community/page.test.tsx create mode 100644 src/app/(main)/items/[itemKey]/page.test.tsx create mode 100644 src/app/(main)/items/[itemKey]/page.tsx create mode 100644 src/app/(main)/outfits/page.test.tsx create mode 100644 src/app/sitemap-items.xml/route.ts create mode 100644 src/app/sitemap-outfits.xml/route.ts create mode 100644 src/app/sitemap-posts.xml/route.ts create mode 100644 src/app/sitemap-static.xml/route.ts delete mode 100644 src/app/sitemap.ts create mode 100644 src/app/sitemap.xml/route.ts create mode 100644 src/components/page/auction-common/AuctionMarketSummary.tsx create mode 100644 src/components/page/community/PostList.test.tsx create mode 100644 src/components/page/items/ItemMetadata.tsx create mode 100644 src/components/page/items/ItemPriceHistory.test.tsx create mode 100644 src/components/page/items/ItemPriceHistory.tsx create mode 100644 src/components/seo/PopularItemLinks.test.tsx create mode 100644 src/lib/community/feed.ts create mode 100644 src/lib/community/public-feed.test.ts create mode 100644 src/lib/community/public-feed.ts create mode 100644 src/lib/items/data.test.ts create mode 100644 src/lib/items/data.ts create mode 100644 src/lib/items/dates.ts create mode 100644 src/lib/items/identity.test.ts create mode 100644 src/lib/items/identity.ts create mode 100644 src/lib/outfits/public-feed.ts create mode 100644 src/lib/outfits/seo.test.ts create mode 100644 src/lib/outfits/seo.ts create mode 100644 src/lib/seo/public-api.ts create mode 100644 src/lib/seo/sitemaps.ts create mode 100644 src/lib/seo/structured-data.ts diff --git a/docs/search-seo.md b/docs/search-seo.md new file mode 100644 index 0000000..4705da8 --- /dev/null +++ b/docs/search-seo.md @@ -0,0 +1,35 @@ +# Search SEO v1 + +공개 커뮤니티/의장 목록과 영구 아이템 페이지를 서버에서 렌더링한다. 검색 순위나 색인 시점을 보장하지 않으며 Search Console 연동은 포함하지 않는다. + +## 공개 URL과 데이터 + +- `/community`: 최신 공개 글 20개. `?page=N`은 1부터 시작하며 다음/이전 링크와 각 페이지의 canonical을 제공한다. +- `/outfits`: 최신 의장 24개. 기존 `?cursor=...`를 다음 페이지 링크에 사용한다. 검색/인기/복합 필터 URL은 noindex,follow다. +- `/items/[itemKey]`: UTF-8 JSON `[topCategory,subCategory,name]`의 unpadded base64url이다. 카탈로그에서 반환한 식별자를 canonical로 사용하고 동등한 다른 인코딩은 308로 보낸다. 잘못된 식별자/없는 아이템은 404다. +- 아이템은 카탈로그 설명 또는 보존된 일간/주간 통계가 있으면 색인 대상이다. 최근 표시 구간이 비어도 자격을 잃지 않고 마지막 보존 집계를 표시한다. +- 일간 30일·주간 120일 통계를 사용한다. 판매 수량과 거래 금액을 구분하며 평균은 거래 금액/판매 수량이다. 두 기간 표는 초기 HTML에 포함된다. + +공개 서버 fetch는 사용자 쿠키와 인증 헤더를 전달하지 않는다. 개인 반응은 클라이언트에서 별도로 읽으며 인증/개인 상태 확인 전 좋아요·즐겨찾기 변경을 막는다. 의장 초기 데이터에는 저장소 서명 URL 대신 `/api/outfits/{id}/images/{imageId}`만 직렬화한다. + +## Provider 의존성 + +Gateway의 기존 prefix를 사용한다. React보다 아래 provider가 먼저 배포되어야 한다. + +- Community: `/dcs/api/posts/sitemap`, `/dcs/api/outfits/sitemap`. 공개 ID와 실제 수정일만 반환하는 ID keyset projection. +- Batch: `/oab/api/item-infos/seo`, `/oab/api/item-infos/sitemap`. 영구 아이템의 공통 색인 자격과 마지막 보존 통계, 카탈로그 복합 keyset projection. +- 각각의 서비스 `docs/search-seo.md`, `docs/item-seo.md`와 `contracts/`의 JSON fixture가 응답 계약이다. + +## Sitemap와 상태 코드 + +`/sitemap.xml`은 `/sitemap-static.xml`, `/sitemap-posts.xml`, `/sitemap-outfits.xml`, `/sitemap-items.xml`의 index다. index는 1,000개씩 경량 projection을 순회해 시작 커서를 찾는다. 자식은 자기 커서만 직접 조회한다. 성공 XML만 프로세스 메모리에 최대 300초 보관하며 만료 뒤 장애는 503이다. 일부 결과를 성공 응답으로 반환하지 않는다. 카탈로그에는 수정 시각이 없어 lastmod를 만들지 않는다. + +글/의장/아이템 상세는 metadata 단계에서 존재 여부를 확인한다. 전체 main-layout Suspense를 제거하고 metadata streaming을 비활성화해 없는 문서를 HTTP 404, 필수 API 장애를 5xx로 반환한다. 경매장 첫 화면도 force-dynamic SSR을 사용한다. 이 선택은 정확한 상태 코드를 위해 초기 응답을 필수 metadata 조회 완료까지 기다리게 한다. + +robots는 기존 API 차단을 유지하면서 공개 의장 이미지 경로만 허용한다. 삭제된 이미지의 404 정책은 유지한다. + +## 검증과 배포 + +`npm run verify`가 format, lint, typecheck, deadcode, Jest, production build를 실행한다. metadata 함수 mock만으로 HTTP 상태를 증명할 수 없으므로 production `next start`에서 200/404/308/5xx, JS-off 본문/표/페이지 링크, XML 오류, 익명/로그인 상호작용을 추가 확인한다. + +배포 순서: 사람의 병합 승인 → Community와 Batch provider 배포/공개 GET 확인 → React 배포 → 운영 읽기 전용 점검. 통계의 300초 캐시, sitemap의 300초 프로세스 캐시, 필수 API 장애 시 5xx를 관찰한다. sitemap index 생성 비용은 전체 공개 카탈로그 수에 따라 증가하며 합성 EXPLAIN은 운영 지연의 보장이 아니다. diff --git a/next.config.ts b/next.config.ts index 4655bba..d6c0b06 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + // Resolve public metadata/existence before streaming a successful response. + htmlLimitedBots: /.*/, async redirects() { return [ { diff --git a/package-lock.json b/package-lock.json index ef5159b..6ed4916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devnogi-front", - "version": "1.0.30", + "version": "1.0.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devnogi-front", - "version": "1.0.30", + "version": "1.0.31", "dependencies": { "@hookform/resolvers": "^5.1.1", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/package.json b/package.json index a1601ad..c5ce303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devnogi-front", - "version": "1.0.30", + "version": "1.0.31", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/(main)/auction-history/AuctionHistoryClient.tsx b/src/app/(main)/auction-history/AuctionHistoryClient.tsx index a83f1c0..4e63a72 100644 --- a/src/app/(main)/auction-history/AuctionHistoryClient.tsx +++ b/src/app/(main)/auction-history/AuctionHistoryClient.tsx @@ -1035,7 +1035,7 @@ export default function Page({ header }: { header?: React.ReactNode }) { return (
{header} @@ -1055,7 +1055,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showCategorySidebar && (
@@ -1231,16 +1235,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showFilterSidebar && ( - setIsFilterModalOpen(false)} - layoutMode={layoutMode} - isExactItemName={!!searchParams.isExactItemName} - onExactItemNameChange={handleExactItemNameChange} - isItemPreviewEnabled={isPreviewEnabled} - onItemPreviewEnabledChange={setIsPreviewEnabled} - /> +
+ setIsFilterModalOpen(false)} + layoutMode={layoutMode} + isExactItemName={!!searchParams.isExactItemName} + onExactItemNameChange={handleExactItemNameChange} + isItemPreviewEnabled={isPreviewEnabled} + onItemPreviewEnabledChange={setIsPreviewEnabled} + /> +
)} {showMobileFilter && ( diff --git a/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx b/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx index fecb884..132d516 100644 --- a/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx +++ b/src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx @@ -25,3 +25,35 @@ describe("auction history detail metadata", () => { }); }); }); + +it("uses a self canonical independent of return_to", async () => { + jest.mocked(getAuctionDetail).mockResolvedValue({ + status: "available", + record: { itemName: "검" }, + } as never); + const result = await generateMetadata({ + params: Promise.resolve({ auctionBuyId: "record-1" }), + searchParams: Promise.resolve({ return_to: "/auction-history?page=2" }), + }); + expect(result.alternates?.canonical).toBe( + "https://www.memonogi.com/auction-history/record-1", + ); +}); +it("propagates missing and failed primary records", async () => { + jest + .mocked(getAuctionDetail) + .mockResolvedValue({ status: "not-found" } as never); + await expect( + generateMetadata({ + params: Promise.resolve({ auctionBuyId: "missing" }), + searchParams: Promise.resolve({}), + }), + ).rejects.toThrow(); + jest.mocked(getAuctionDetail).mockResolvedValue({ status: "error" } as never); + await expect( + generateMetadata({ + params: Promise.resolve({ auctionBuyId: "error" }), + searchParams: Promise.resolve({}), + }), + ).rejects.toThrow(); +}); diff --git a/src/app/(main)/auction-history/[auctionBuyId]/page.tsx b/src/app/(main)/auction-history/[auctionBuyId]/page.tsx index 95b73f1..5c815f8 100644 --- a/src/app/(main)/auction-history/[auctionBuyId]/page.tsx +++ b/src/app/(main)/auction-history/[auctionBuyId]/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import AuctionDetailView from "@/components/page/auction-common/AuctionDetailView"; import { getAuctionDetail, safeReturnTo } from "@/lib/auction/detail"; +import { canonicalUrl } from "@/lib/seo/site"; interface PageProps { params: Promise<{ auctionBuyId: string }>; @@ -15,16 +17,24 @@ export async function generateMetadata({ }: PageProps): Promise { const { auctionBuyId } = await params; const result = await getAuctionDetail("history", auctionBuyId); + if (result.status === "not-found") notFound(); + if (result.status === "error" || !result.record) + throw new Error("경매장 상세 정보를 불러오지 못했습니다."); return { title: { absolute: result.record ? `${result.record.itemName} 거래 내역 | 마비노기` : "경매장 거래 내역 | 마비노기", }, - robots: - result.status === "available" - ? undefined - : { index: false, follow: false }, + description: `${result.record.itemName}의 마비노기 경매장 거래 내역과 아이템 시세를 확인하세요.`, + alternates: { + canonical: canonicalUrl( + `/auction-history/${encodeURIComponent(auctionBuyId)}`, + ), + }, + openGraph: { + url: canonicalUrl(`/auction-history/${encodeURIComponent(auctionBuyId)}`), + }, }; } @@ -34,6 +44,9 @@ export default async function AuctionHistoryDetailPage({ }: PageProps) { const [{ auctionBuyId }, query] = await Promise.all([params, searchParams]); const result = await getAuctionDetail("history", auctionBuyId); + if (result.status === "not-found") notFound(); + if (result.status === "error" || !result.record) + throw new Error("경매장 상세 정보를 불러오지 못했습니다."); return ( + function AuctionMarketSummary() { + return

시세 요약

; + }, +); + jest.mock("./AuctionHistoryClient", () => { return function AuctionHistoryClient({ header, @@ -24,7 +32,7 @@ describe("AuctionHistoryPage SEO landing content", () => { expect( screen.getByRole("heading", { level: 1, - name: "경매장 거래내역", + name: "마비노기 경매장 거래내역·시세", }), ).toBeInTheDocument(); expect(screen.getByText(/NEXON Open API를 기반/)).toBeInTheDocument(); diff --git a/src/app/(main)/auction-history/page.tsx b/src/app/(main)/auction-history/page.tsx index b4b6113..5229e95 100644 --- a/src/app/(main)/auction-history/page.tsx +++ b/src/app/(main)/auction-history/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import AuctionMarketSummary from "@/components/page/auction-common/AuctionMarketSummary"; import AuctionPageHeader from "@/components/page/auction-common/AuctionPageHeader"; import { itemCategories } from "@/data/item-category"; import { canonicalUrl } from "@/lib/seo/site"; @@ -12,6 +13,8 @@ type AuctionHistoryPageProps = { searchParams: Promise; }; +export const dynamic = "force-dynamic"; + export async function generateMetadata({ searchParams, }: AuctionHistoryPageProps): Promise { @@ -40,6 +43,7 @@ export default function AuctionHistoryPage() { +
diff --git a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx index 90b4c2b..eecb20a 100644 --- a/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx +++ b/src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx @@ -982,7 +982,7 @@ export default function Page({ header }: { header?: React.ReactNode }) { return (
{header} @@ -1002,7 +1002,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showCategorySidebar && (
@@ -1176,16 +1180,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
{showFilterSidebar && ( - setIsFilterModalOpen(false)} - layoutMode={layoutMode} - isExactItemName={!!searchParams.isExactItemName} - onExactItemNameChange={handleExactItemNameChange} - isItemPreviewEnabled={isPreviewEnabled} - onItemPreviewEnabledChange={setIsPreviewEnabled} - /> +
+ setIsFilterModalOpen(false)} + layoutMode={layoutMode} + isExactItemName={!!searchParams.isExactItemName} + onExactItemNameChange={handleExactItemNameChange} + isItemPreviewEnabled={isPreviewEnabled} + onItemPreviewEnabledChange={setIsPreviewEnabled} + /> +
)} {showMobileFilter && ( diff --git a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx index c94533a..da2088e 100644 --- a/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx +++ b/src/app/(main)/auction-realtime/[auctionId]/page.test.tsx @@ -25,3 +25,35 @@ describe("auction realtime detail metadata", () => { }); }); }); + +it("uses a self canonical independent of return_to", async () => { + jest.mocked(getAuctionDetail).mockResolvedValue({ + status: "available", + record: { itemName: "검" }, + } as never); + const result = await generateMetadata({ + params: Promise.resolve({ auctionId: "record-1" }), + searchParams: Promise.resolve({ return_to: "/auction-realtime?page=2" }), + }); + expect(result.alternates?.canonical).toBe( + "https://www.memonogi.com/auction-realtime/record-1", + ); +}); +it("propagates missing and failed primary records", async () => { + jest + .mocked(getAuctionDetail) + .mockResolvedValue({ status: "not-found" } as never); + await expect( + generateMetadata({ + params: Promise.resolve({ auctionId: "missing" }), + searchParams: Promise.resolve({}), + }), + ).rejects.toThrow(); + jest.mocked(getAuctionDetail).mockResolvedValue({ status: "error" } as never); + await expect( + generateMetadata({ + params: Promise.resolve({ auctionId: "error" }), + searchParams: Promise.resolve({}), + }), + ).rejects.toThrow(); +}); diff --git a/src/app/(main)/auction-realtime/[auctionId]/page.tsx b/src/app/(main)/auction-realtime/[auctionId]/page.tsx index d94bbfe..312b7a6 100644 --- a/src/app/(main)/auction-realtime/[auctionId]/page.tsx +++ b/src/app/(main)/auction-realtime/[auctionId]/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import AuctionDetailView from "@/components/page/auction-common/AuctionDetailView"; import { getAuctionDetail, safeReturnTo } from "@/lib/auction/detail"; +import { canonicalUrl } from "@/lib/seo/site"; interface PageProps { params: Promise<{ auctionId: string }>; @@ -15,16 +17,24 @@ export async function generateMetadata({ }: PageProps): Promise { const { auctionId } = await params; const result = await getAuctionDetail("realtime", auctionId); + if (result.status === "not-found") notFound(); + if (result.status === "error" || !result.record) + throw new Error("경매장 상세 정보를 불러오지 못했습니다."); return { title: { absolute: result.record ? `${result.record.itemName} 실시간 거래 | 마비노기` : "경매장 실시간 거래 | 마비노기", }, - robots: - result.status === "available" - ? undefined - : { index: false, follow: false }, + description: `${result.record.itemName}의 마비노기 경매장 실시간 거래과 아이템 시세를 확인하세요.`, + alternates: { + canonical: canonicalUrl( + `/auction-realtime/${encodeURIComponent(auctionId)}`, + ), + }, + openGraph: { + url: canonicalUrl(`/auction-realtime/${encodeURIComponent(auctionId)}`), + }, }; } @@ -34,6 +44,9 @@ export default async function AuctionRealtimeDetailPage({ }: PageProps) { const [{ auctionId }, query] = await Promise.all([params, searchParams]); const result = await getAuctionDetail("realtime", auctionId); + if (result.status === "not-found") notFound(); + if (result.status === "error" || !result.record) + throw new Error("경매장 상세 정보를 불러오지 못했습니다."); return ( = [ @@ -21,7 +22,12 @@ const tabs: Array<{ value: FeedTab; label: string }> = [ { value: "mine", label: "내 글" }, ]; -function CommunityPageContent() { +interface Props { + initial?: BackendPostsData; + startPage?: number; +} + +function CommunityPageContent({ initial, startPage = 1 }: Props) { const keyword = useSearchParams().get("q") ?? ""; const [tab, setTab] = useState("latest"); const [composerOpen, setComposerOpen] = useState(false); @@ -49,10 +55,10 @@ function CommunityPageContent() {

- 에린 이야기 + 마비노기 커뮤니티

- 길게 고민하지 말고, 오늘의 한 장면을 가볍게 나눠보세요. + 에린 이야기 · 마비노기 질문과 플레이 경험을 나눠보세요.

{keyword && ( @@ -101,7 +107,12 @@ function CommunityPageContent() {
)}
- + {showPopular && (