Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/search-seo.md
Original file line number Diff line number Diff line change
@@ -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은 운영 지연의 보장이 아니다.
2 changes: 2 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
"version": "1.0.30",
"version": "1.0.31",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-production-workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => {
test("package and lockfile agree on the next release version", () => {
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.30");
assert.equal(pkg.version, "1.0.31");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
});
2 changes: 1 addition & 1 deletion scripts/workflow-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => {
);
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.30");
assert.equal(pkg.version, "1.0.31");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
assert.match(
Expand Down
32 changes: 19 additions & 13 deletions src/app/(main)/auction-history/AuctionHistoryClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
return (
<div className="-mx-4 -my-6 md:-mx-6 md:-my-8">
<div
className={`flex justify-center ${showCategorySidebar ? "px-72" : showFilterSidebar ? "pr-72" : ""}`}
className={`flex justify-center ${showCategorySidebar ? "min-[1240px]:pr-72 min-[1452px]:pl-72" : showFilterSidebar ? "min-[1240px]:pr-72" : ""}`}
>
<div className="w-full max-w-4xl px-4 pt-4 md:px-6 md:pt-6">
{header}
Expand All @@ -1055,7 +1055,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
<div className="select-none min-h-full bg-[var(--color-ds-background)] dark:bg-navy-900 -mx-4 md:-mx-6 -my-6 md:-my-8">
{showCategorySidebar && (
<div
className="fixed top-[140px] bottom-8 w-56 z-40"
className="hidden min-[1452px]:block fixed top-[140px] bottom-8 w-56 z-40"
style={{ left: "max(16px, calc(50% - 678px))" }}
>
<CategorySection
Expand All @@ -1070,7 +1070,11 @@ export default function Page({ header }: { header?: React.ReactNode }) {

<div
className={`min-h-full flex justify-center [scrollbar-gutter:stable] ${
showCategorySidebar ? "px-72" : showFilterSidebar ? "pr-72" : ""
showCategorySidebar
? "min-[1240px]:pr-72 min-[1452px]:pl-72"
: showFilterSidebar
? "min-[1240px]:pr-72"
: ""
}`}
>
<div className="w-full max-w-4xl px-4 md:px-6 pt-4 md:pt-6 pb-4 md:pb-8">
Expand Down Expand Up @@ -1231,16 +1235,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
</div>

{showFilterSidebar && (
<SearchFilterCard
onFilterApply={handleFilterApply}
isModal={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
layoutMode={layoutMode}
isExactItemName={!!searchParams.isExactItemName}
onExactItemNameChange={handleExactItemNameChange}
isItemPreviewEnabled={isPreviewEnabled}
onItemPreviewEnabledChange={setIsPreviewEnabled}
/>
<div className="hidden min-[1240px]:block">
<SearchFilterCard
onFilterApply={handleFilterApply}
isModal={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
layoutMode={layoutMode}
isExactItemName={!!searchParams.isExactItemName}
onExactItemNameChange={handleExactItemNameChange}
isItemPreviewEnabled={isPreviewEnabled}
onItemPreviewEnabledChange={setIsPreviewEnabled}
/>
</div>
)}

{showMobileFilter && (
Expand Down
32 changes: 32 additions & 0 deletions src/app/(main)/auction-history/[auctionBuyId]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
21 changes: 17 additions & 4 deletions src/app/(main)/auction-history/[auctionBuyId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -15,16 +17,24 @@ export async function generateMetadata({
}: PageProps): Promise<Metadata> {
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)}`),
},
};
}

Expand All @@ -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 (
<AuctionDetailView
result={result}
Expand Down
10 changes: 9 additions & 1 deletion src/app/(main)/auction-history/page.seo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import React from "react";

import AuctionHistoryPage from "./page";

jest.mock(
"@/components/page/auction-common/AuctionMarketSummary",
() =>
function AuctionMarketSummary() {
return <p>시세 요약</p>;
},
);

jest.mock("./AuctionHistoryClient", () => {
return function AuctionHistoryClient({
header,
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/app/(main)/auction-history/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,6 +13,8 @@ type AuctionHistoryPageProps = {
searchParams: Promise<AuctionSearchParams>;
};

export const dynamic = "force-dynamic";

export async function generateMetadata({
searchParams,
}: AuctionHistoryPageProps): Promise<Metadata> {
Expand Down Expand Up @@ -40,6 +43,7 @@ export default function AuctionHistoryPage() {
<AuctionHistoryClient
header={
<AuctionPageHeader currentView="history">
<AuctionMarketSummary />
<div className="mt-2 flex flex-wrap gap-x-4 text-sm text-[var(--color-ds-text)]">
<details className="group basis-full sm:basis-auto sm:flex-1">
<summary className="min-h-11 cursor-pointer content-center rounded-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
Expand Down
32 changes: 19 additions & 13 deletions src/app/(main)/auction-realtime/AuctionRealtimeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
return (
<div className="-mx-4 -my-6 md:-mx-6 md:-my-8">
<div
className={`flex justify-center ${showCategorySidebar ? "px-72" : showFilterSidebar ? "pr-72" : ""}`}
className={`flex justify-center ${showCategorySidebar ? "min-[1240px]:pr-72 min-[1452px]:pl-72" : showFilterSidebar ? "min-[1240px]:pr-72" : ""}`}
>
<div className="w-full max-w-4xl px-4 pt-4 md:px-6 md:pt-6">
{header}
Expand All @@ -1002,7 +1002,7 @@ export default function Page({ header }: { header?: React.ReactNode }) {
<div className="select-none min-h-full bg-[var(--color-ds-background)] dark:bg-navy-900 -mx-4 md:-mx-6 -my-6 md:-my-8">
{showCategorySidebar && (
<div
className="fixed top-[140px] bottom-8 w-56 z-40"
className="hidden min-[1452px]:block fixed top-[140px] bottom-8 w-56 z-40"
style={{ left: "max(16px, calc(50% - 678px))" }}
>
<CategorySection
Expand All @@ -1017,7 +1017,11 @@ export default function Page({ header }: { header?: React.ReactNode }) {

<div
className={`min-h-full flex justify-center [scrollbar-gutter:stable] ${
showCategorySidebar ? "px-72" : showFilterSidebar ? "pr-72" : ""
showCategorySidebar
? "min-[1240px]:pr-72 min-[1452px]:pl-72"
: showFilterSidebar
? "min-[1240px]:pr-72"
: ""
}`}
>
<div className="w-full max-w-4xl px-4 md:px-6 pt-4 md:pt-6 pb-4 md:pb-8">
Expand Down Expand Up @@ -1176,16 +1180,18 @@ export default function Page({ header }: { header?: React.ReactNode }) {
</div>

{showFilterSidebar && (
<SearchFilterCard
onFilterApply={handleFilterApply}
isModal={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
layoutMode={layoutMode}
isExactItemName={!!searchParams.isExactItemName}
onExactItemNameChange={handleExactItemNameChange}
isItemPreviewEnabled={isPreviewEnabled}
onItemPreviewEnabledChange={setIsPreviewEnabled}
/>
<div className="hidden min-[1240px]:block">
<SearchFilterCard
onFilterApply={handleFilterApply}
isModal={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
layoutMode={layoutMode}
isExactItemName={!!searchParams.isExactItemName}
onExactItemNameChange={handleExactItemNameChange}
isItemPreviewEnabled={isPreviewEnabled}
onItemPreviewEnabledChange={setIsPreviewEnabled}
/>
</div>
)}

{showMobileFilter && (
Expand Down
32 changes: 32 additions & 0 deletions src/app/(main)/auction-realtime/[auctionId]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading
Loading