From 505398ec9fb119f4f294563d87b35bd1ebd8d0c6 Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 28 Apr 2026 21:05:20 +0900 Subject: [PATCH] Board access guard, tag page real impl, rankings PHP-parity, freespin, more admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board access (mirrors gnuboard bo_read/write/comment_level): - legacy-board.ts: getBoardMeta now returns readLevel/writeLevel/commentLevel/useSecret/useCert - checkBoardAccess(meta, userLevel, action) helper used on: /[boardSlug] (read), /[boardSlug]/[wrId] (read), /[boardSlug]/write (write) - Insufficient level → friendly lock screen with "필요 Lv.X" + login redirect for anon Tag indexing (real): - /tag/[tag]: pulls from inspection2.g5_eyoom_tag_write (cached subject/hit/datetime) - ILIKE wr_tag, paginated 20 per page, total count in header - highlights matched tag; lists other tags on each post Rankings PHP-parity (lib/member.rank.lib.php): - NOT IN ('admin','admin2','admin3','admin4','roy') - mb_point > 0 AND mb_level < 11 - LEFT JOIN g5_eyoom_member.level (이윰 레벨) - ORDER BY mb_point DESC - Both sidebar getMemberRankings and home TopWinners switched - Visible result: 보내드림 1.66M / 코뚜롱 1.48M / 스티브박 1.40M (admin* hidden) Freespin (game engine): - inspection2.app_freespin (mb_id, slug, remaining) - Scatter ≥3 grants 5-10 free spins - Next spin uses bet=0; remaining-- recorded transactionally - placeBetAndSpin returns freeSpinsRemaining Admin write: - /admin/plugin/recaptcha (saved into public.app_settings) - /admin/roulette (g5_roulette_list rename/delete + reward list) - /admin/lottery/winners (lottery_history, paginated 50) Verify: 50 iter × 16 = 800/800 PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/app/[boardSlug]/[wrId]/page.tsx | 21 +++- .../apps/web/src/app/[boardSlug]/page.tsx | 26 ++++- .../web/src/app/[boardSlug]/write/page.tsx | 9 +- .../src/app/admin/lottery/winners/page.tsx | 66 ++++++++++++ .../src/app/admin/plugin/recaptcha/page.tsx | 67 ++++++++++++ .../apps/web/src/app/admin/roulette/page.tsx | 100 ++++++++++++++++++ next-app/apps/web/src/app/tag/[tag]/page.tsx | 94 ++++++++++++++-- next-app/apps/web/src/lib/game-engine.ts | 56 ++++++++-- next-app/apps/web/src/lib/legacy-board.ts | 60 +++++++++-- next-app/apps/web/src/lib/page-data.ts | 60 ++++++++--- next-app/scripts/verify-cross.mjs | 4 +- 11 files changed, 521 insertions(+), 42 deletions(-) create mode 100644 next-app/apps/web/src/app/admin/lottery/winners/page.tsx create mode 100644 next-app/apps/web/src/app/admin/plugin/recaptcha/page.tsx create mode 100644 next-app/apps/web/src/app/admin/roulette/page.tsx diff --git a/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx b/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx index d034a0a..388bd6b 100644 --- a/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx +++ b/next-app/apps/web/src/app/[boardSlug]/[wrId]/page.tsx @@ -1,5 +1,6 @@ -import { notFound } from 'next/navigation'; -import { getBoardMeta, getPost } from '@/lib/legacy-board'; +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; +import { getBoardMeta, getPost, checkBoardAccess } from '@/lib/legacy-board'; import { getThemeForPath } from '@/lib/theme'; import { getCurrentSiteUser, getCurrentPathname } from '@/lib/page-data'; import { legacySql } from '@slot/db/legacy'; @@ -10,11 +11,25 @@ export default async function PostPage({ params }: { params: Promise<{ boardSlug const { boardSlug, wrId } = await params; const meta = await getBoardMeta(boardSlug); if (!meta) return notFound(); + const user0 = await getCurrentSiteUser(); + const access = checkBoardAccess(meta, user0?.level ?? 0, 'read'); + if (!access.ok) { + if (access.needsLogin) redirect(`/login?next=/${boardSlug}/${wrId}`); + return ( +
+
🔒
+

{meta.title}

+

{access.reason}

+

현재 Lv.{user0?.level ?? 0} / 필요 Lv.{access.required}

+ 홈으로 +
+ ); + } const post = await getPost(boardSlug, wrId); if (!post) return notFound(); const [theme, user] = await Promise.all([ getThemeForPath(await getCurrentPathname()), - getCurrentSiteUser(), + Promise.resolve(user0), ]); // hit++ async (don't block render) diff --git a/next-app/apps/web/src/app/[boardSlug]/page.tsx b/next-app/apps/web/src/app/[boardSlug]/page.tsx index 2e15c42..d4259a8 100644 --- a/next-app/apps/web/src/app/[boardSlug]/page.tsx +++ b/next-app/apps/web/src/app/[boardSlug]/page.tsx @@ -1,7 +1,9 @@ -import { notFound } from 'next/navigation'; -import { getBoardMeta, listPosts } from '@/lib/legacy-board'; +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; +import { getBoardMeta, listPosts, checkBoardAccess } from '@/lib/legacy-board'; import { getThemeForPath } from '@/lib/theme'; import { headers } from 'next/headers'; +import { getCurrentSiteUser } from '@/lib/page-data'; export const dynamic = 'force-dynamic'; @@ -11,6 +13,26 @@ export default async function BoardPage({ params, searchParams }: { params: Prom const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1); const meta = await getBoardMeta(boardSlug); if (!meta) return notFound(); + + const user = await getCurrentSiteUser(); + const userLevel = user?.level ?? 0; + const access = checkBoardAccess(meta, userLevel, 'read'); + if (!access.ok) { + if (access.needsLogin) redirect(`/login?next=/${boardSlug}`); + return ( +
+
🔒
+

{meta.title}

+

{access.reason}

+

현재 회원 레벨: Lv.{userLevel} / 필요 레벨: Lv.{access.required}

+
+ 홈 + 내 정보 +
+
+ ); + } + const { items, totalPages } = await listPosts(boardSlug, page); const h = await headers(); const theme = await getThemeForPath(h.get('x-pathname') ?? `/${boardSlug}`); diff --git a/next-app/apps/web/src/app/[boardSlug]/write/page.tsx b/next-app/apps/web/src/app/[boardSlug]/write/page.tsx index f30aad6..9367720 100644 --- a/next-app/apps/web/src/app/[boardSlug]/write/page.tsx +++ b/next-app/apps/web/src/app/[boardSlug]/write/page.tsx @@ -1,5 +1,5 @@ import { StubPage } from '@/lib/page-shells'; -import { getBoardMeta } from '@/lib/legacy-board'; +import { getBoardMeta, checkBoardAccess } from '@/lib/legacy-board'; import { getCurrentSiteUser } from '@/lib/page-data'; import { redirect, notFound } from 'next/navigation'; export const dynamic = 'force-dynamic'; @@ -9,6 +9,13 @@ export default async function Page({ params }: { params: Promise<{ boardSlug: st if (!user) redirect(`/login?next=/${boardSlug}/write`); const meta = await getBoardMeta(boardSlug); if (!meta) return notFound(); + const access = checkBoardAccess(meta, user!.level, 'write'); + if (!access.ok) { + return +

현재 Lv.{user!.level} / 필요 Lv.{access.required}

+ ← 목록으로 +
; + } return
diff --git a/next-app/apps/web/src/app/admin/lottery/winners/page.tsx b/next-app/apps/web/src/app/admin/lottery/winners/page.tsx new file mode 100644 index 0000000..bc3e91a --- /dev/null +++ b/next-app/apps/web/src/app/admin/lottery/winners/page.tsx @@ -0,0 +1,66 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; + +export const dynamic = 'force-dynamic'; + +interface Row { lo_id: number; mb_id: string; reward_rank: string; reward_name: string; reward_point: number; lo_datetime: Date } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +export default async function LotteryWinnersAdmin({ searchParams }: { searchParams: Promise<{ page?: string }> }) { + await requireAdmin(); + const sp = await searchParams; + const page = Math.max(1, Number(sp.page ?? 1) | 0); + const offset = (page - 1) * 50; + const rows = await legacySql` + SELECT lo_id, mb_id, reward_rank, reward_name, reward_point, lo_datetime + FROM inspection2.lottery_history ORDER BY lo_id DESC LIMIT 50 OFFSET ${offset} + `.catch(() => []); + const totalRow = await legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM inspection2.lottery_history`.catch(() => [{ c: '0' }]); + const total = Number(totalRow[0]?.c ?? 0); + const totalPages = Math.max(1, Math.ceil(total / 50)); + + return ( +
+
+
룰렛 · 복권
+

복권 당첨 내역 ({total.toLocaleString()})

+
+
+ + + + + + {rows.map((r) => ( + + + + + + + + + ))} + {rows.length === 0 && } + +
ID회원등수상품명포인트추첨일
{r.lo_id}{r.mb_id}{r.reward_rank}등{r.reward_name}{r.reward_point > 0 ? `+${r.reward_point.toLocaleString()}p` : '-'}{r.lo_datetime && new Date(r.lo_datetime).toISOString().slice(0,10)}
당첨 내역 없음
+
+ {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/next-app/apps/web/src/app/admin/plugin/recaptcha/page.tsx b/next-app/apps/web/src/app/admin/plugin/recaptcha/page.tsx new file mode 100644 index 0000000..7876c87 --- /dev/null +++ b/next-app/apps/web/src/app/admin/plugin/recaptcha/page.tsx @@ -0,0 +1,67 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function saveRecaptcha(formData: FormData) { + 'use server'; + await requireAdmin(); + const siteKey = String(formData.get('site_key') ?? '').slice(0, 100); + const secretKey = String(formData.get('secret_key') ?? '').slice(0, 200); + const version = String(formData.get('version') ?? 'v3').slice(0, 5); + const enabled = formData.get('enabled') ? 1 : 0; + await legacySql` + INSERT INTO public.app_settings (key, value) + VALUES ('recaptcha', ${JSON.stringify({ siteKey, secretKey, version, enabled })}::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `.catch(() => {}); + revalidatePath('/admin/plugin/recaptcha'); +} + +export default async function RecaptchaAdmin() { + await requireAdmin(); + const rows = await legacySql<{ value: { siteKey?: string; secretKey?: string; version?: string; enabled?: number } }[]>` + SELECT value FROM public.app_settings WHERE key = 'recaptcha' LIMIT 1 + `.catch(() => []); + const cur = rows[0]?.value ?? {}; + + return ( +
+
+
플러그인
+

reCAPTCHA 설정

+

Google reCAPTCHA v2/v3 키 저장. 가입/로그인/글쓰기 폼 보호.

+
+ + + +
+ + +
+ + + +
+ ); +} + +function Field({ name, label, defaultValue, type = 'text' }: { name: string; label: string; defaultValue: string; type?: string }) { + return ( +
+ + +
+ ); +} diff --git a/next-app/apps/web/src/app/admin/roulette/page.tsx b/next-app/apps/web/src/app/admin/roulette/page.tsx new file mode 100644 index 0000000..ed23b5b --- /dev/null +++ b/next-app/apps/web/src/app/admin/roulette/page.tsx @@ -0,0 +1,100 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +interface Row { idx: number; roul_title: string; roul_type: string; roul_contents: string } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function deleteRoulette(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = Number(formData.get('idx') ?? 0); + if (!id) return; + await legacySql`DELETE FROM inspection2.g5_roulette_list WHERE idx = ${id}`.catch(() => {}); + revalidatePath('/admin/roulette'); +} + +async function renameRoulette(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = Number(formData.get('idx') ?? 0); + const title = String(formData.get('title') ?? '').slice(0, 200); + if (!id || !title) return; + await legacySql`UPDATE inspection2.g5_roulette_list SET roul_title = ${title} WHERE idx = ${id}`.catch(() => {}); + revalidatePath('/admin/roulette'); +} + +export default async function RouletteAdmin() { + await requireAdmin(); + const rows = await legacySql` + SELECT idx, roul_title, roul_type, roul_contents FROM inspection2.g5_roulette_list ORDER BY idx DESC LIMIT 50 + `.catch(() => []); + const rewardRows = await legacySql<{ idx: number; mb_id: string; reward_name: string; reward_point: number; lo_datetime: Date }[]>` + SELECT idx, mb_id, reward_name, reward_point, lo_datetime FROM inspection2.g5_roulette_reward_list ORDER BY idx DESC LIMIT 30 + `.catch(() => []); + + return ( +
+
+
룰렛 · 복권
+

룰렛 회차 관리

+

g5_roulette_list. 회차 제목 변경/삭제, 최근 30 당첨자 노출.

+
+
+

회차 목록 ({rows.length})

+
+ + + + + + {rows.map((r) => ( + + + + + + + ))} + {rows.length === 0 && } + +
ID제목유형관리
{r.idx} +
+ + + +
+
{r.roul_type} +
+ + +
+
등록된 룰렛 없음
+
+
+
+

최근 당첨자 (g5_roulette_reward_list, 30건)

+
    + {rewardRows.map((r) => ( +
  • + {r.mb_id} 님이 {r.reward_name} 당첨 + + {r.reward_point > 0 && +{Number(r.reward_point).toLocaleString()}p} + {r.lo_datetime && new Date(r.lo_datetime).toLocaleDateString('ko-KR')} + +
  • + ))} + {rewardRows.length === 0 &&
  • 기록 없음
  • } +
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/tag/[tag]/page.tsx b/next-app/apps/web/src/app/tag/[tag]/page.tsx index 847791f..8871496 100644 --- a/next-app/apps/web/src/app/tag/[tag]/page.tsx +++ b/next-app/apps/web/src/app/tag/[tag]/page.tsx @@ -1,7 +1,89 @@ -import { StubPage } from '@/lib/page-shells'; -export default async function Page({ params }: { params: Promise<{ tag: string }> }) { - const { tag } = await params; - return -

태그 인덱싱은 신규 posts 테이블 ETL 후 활성화됩니다.

-
; +import Link from 'next/link'; +import { legacySql } from '@slot/db/legacy'; +import { Hash, MessageSquare, Eye } from 'lucide-react'; + +export const dynamic = 'force-dynamic'; + +interface TagWriteRow { + bo_table: string; wr_id: number; wr_subject: string; + mb_nick: string; mb_id: string; tw_datetime: Date; + wr_hit: number; wr_tag: string; +} + +export default async function TagPage({ params, searchParams }: { params: Promise<{ tag: string }>; searchParams: Promise<{ page?: string }> }) { + const { tag: rawTag } = await params; + const sp = await searchParams; + const tag = decodeURIComponent(rawTag); + const page = Math.max(1, Number(sp.page ?? 1) | 0); + const offset = (page - 1) * 20; + + const rows = await legacySql` + SELECT bo_table, wr_id, wr_subject, mb_nick, mb_id, tw_datetime, wr_hit, wr_tag + FROM inspection2.g5_eyoom_tag_write + WHERE wr_tag ILIKE ${'%' + tag + '%'} + ORDER BY tw_datetime DESC + LIMIT 20 OFFSET ${offset} + `.catch(() => []); + + const totalRow = await legacySql<{ c: string }[]>` + SELECT COUNT(*)::text AS c FROM inspection2.g5_eyoom_tag_write WHERE wr_tag ILIKE ${'%' + tag + '%'} + `.catch(() => [{ c: '0' }]); + const total = Number(totalRow[0]?.c ?? 0); + const totalPages = Math.max(1, Math.ceil(total / 20)); + + return ( +
+
+
TAG
+

+ {tag} +

+

{total.toLocaleString()}건의 게시글 (페이지 {page}/{totalPages})

+
+ + {rows.length === 0 ? ( +

+ 이 태그가 달린 글이 없습니다. +

+ ) : ( +
    + {rows.map((r) => { + const tags = (r.wr_tag ?? '').split(',').map((s) => s.trim()).filter(Boolean); + return ( +
  • + +
    + /{r.bo_table} + {r.wr_subject} +
    + {tags.slice(0, 5).map((t, i) => ( + + #{t} + + ))} +
    +
    +
    + {r.wr_hit ?? 0} + {r.mb_nick} + {r.tw_datetime ? new Date(r.tw_datetime).toLocaleDateString('ko-KR') : '-'} +
    + +
  • + ); + })} +
+ )} + + {totalPages > 1 && ( + + )} + + ← 모든 태그 보기 +
+ ); } diff --git a/next-app/apps/web/src/lib/game-engine.ts b/next-app/apps/web/src/lib/game-engine.ts index 8abd70c..9b3bb46 100644 --- a/next-app/apps/web/src/lib/game-engine.ts +++ b/next-app/apps/web/src/lib/game-engine.ts @@ -207,24 +207,64 @@ export function spin(g: GameDef, bet: number): SpinResult { return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!', freeSpin, scatterCount }; } -export async function placeBetAndSpin(slug: string, user: SiteUser, bet: number): Promise<{ ok: boolean; error?: string; result?: SpinResult; newPoint?: number }> { +async function ensureFreespinTable() { + await legacySql` + CREATE TABLE IF NOT EXISTS inspection2.app_freespin ( + mb_id TEXT NOT NULL, + slug TEXT NOT NULL, + remaining INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (mb_id, slug) + ) + `.catch(() => {}); +} + +export async function getFreespinCount(loginId: string, slug: string): Promise { + await ensureFreespinTable(); + const r = await legacySql<{ remaining: number }[]>` + SELECT remaining FROM inspection2.app_freespin WHERE mb_id = ${loginId} AND slug = ${slug} + `.catch(() => []); + return Number(r[0]?.remaining ?? 0); +} + +export async function placeBetAndSpin(slug: string, user: SiteUser, bet: number): Promise<{ ok: boolean; error?: string; result?: SpinResult; newPoint?: number; freeSpinsRemaining?: number }> { const g = GAMES[slug]; if (!g) return { ok: false, error: 'unknown_game' }; - const b = Math.trunc(bet); - if (b < g.minBet || b > g.maxBet) return { ok: false, error: `bet_out_of_range:${g.minBet}-${g.maxBet}` }; + await ensureFreespinTable(); + const free = await getFreespinCount(user.loginId, slug); + const b = free > 0 ? 0 : Math.trunc(bet); + if (free === 0 && (b < g.minBet || b > g.maxBet)) return { ok: false, error: `bet_out_of_range:${g.minBet}-${g.maxBet}` }; const memberRow = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${user.loginId}`.catch(() => []); const balance = Number(memberRow[0]?.mb_point ?? 0); - if (balance < b) return { ok: false, error: 'insufficient_point' }; + if (b > 0 && balance < b) return { ok: false, error: 'insufficient_point' }; - const result = spin(g, b); - const newPoint = balance + result.net; + // For freespin, internally use a virtual bet of g.minBet for payout calculation but no debit + const virtualBet = b > 0 ? b : Math.max(g.minBet, 100); + const result = spin(g, virtualBet); + const debit = b; + const credit = result.payout; + const netDelta = credit - debit; + const newPoint = balance + netDelta; const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' '); + let freeSpinsRemaining = free; await legacySql.begin(async (tx) => { await tx`UPDATE inspection2.g5_member SET mb_point = ${newPoint} WHERE mb_id = ${user.loginId}`; await tx` INSERT INTO inspection2.g5_point (mb_id, po_datetime, po_content, po_point, po_use_point, po_expire_point, po_expired, po_expire_date, po_mb_point, po_rel_table, po_rel_id, po_rel_action) - VALUES (${user.loginId}, ${nowStr}, ${'[' + g.label + '] ' + result.message}, ${result.net}, 0, ${Math.max(0, result.net)}, 0, '9999-12-31', ${newPoint}, '@game', ${slug}, ${result.win ? 'game-win' : 'game-loss'}) + VALUES (${user.loginId}, ${nowStr}, ${'[' + g.label + (free > 0 ? ' FREE' : '') + '] ' + result.message}, ${netDelta}, 0, ${Math.max(0, netDelta)}, 0, '9999-12-31', ${newPoint}, '@game', ${slug}, ${result.win ? 'game-win' : 'game-loss'}) `; + if (free > 0) { + freeSpinsRemaining = free - 1; + await tx`UPDATE inspection2.app_freespin SET remaining = ${freeSpinsRemaining} WHERE mb_id = ${user.loginId} AND slug = ${slug}`; + } + if (result.freeSpin && (result.scatterCount ?? 0) >= 3) { + const grant = Math.min(10, (result.scatterCount! - 2) * 5); + freeSpinsRemaining += grant; + await tx` + INSERT INTO inspection2.app_freespin (mb_id, slug, remaining) + VALUES (${user.loginId}, ${slug}, ${grant}) + ON CONFLICT (mb_id, slug) DO UPDATE SET remaining = inspection2.app_freespin.remaining + ${grant} + `; + } }).catch((e) => { console.error('placeBetAndSpin fail', e); throw e; }); - return { ok: true, result, newPoint }; + return { ok: true, result, newPoint, freeSpinsRemaining }; } diff --git a/next-app/apps/web/src/lib/legacy-board.ts b/next-app/apps/web/src/lib/legacy-board.ts index 2da41db..70f877f 100644 --- a/next-app/apps/web/src/lib/legacy-board.ts +++ b/next-app/apps/web/src/lib/legacy-board.ts @@ -5,26 +5,72 @@ import { legacySql } from '@slot/db/legacy'; import type { PostListItem } from '@slot/themes'; -export type LegacyBoardMeta = { slug: string; title: string; description: string | null }; +export type LegacyBoardMeta = { + slug: string; title: string; description: string | null; + readLevel: number; writeLevel: number; commentLevel: number; + useSecret: number; useCert: number; +}; + +type BoardRow = { + bo_table: string; bo_subject: string; + bo_read_level: number; bo_write_level: number; bo_comment_level: number; + bo_use_secret: number; bo_use_cert: string | null; +}; export async function listBoards(): Promise { - const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>` - SELECT bo_table, bo_subject + const rows = await legacySql` + SELECT bo_table, bo_subject, + COALESCE(bo_read_level, 1) AS bo_read_level, + COALESCE(bo_write_level, 1) AS bo_write_level, + COALESCE(bo_comment_level, 1) AS bo_comment_level, + COALESCE(bo_use_secret, 0) AS bo_use_secret, + bo_use_cert FROM inspection2.g5_board WHERE bo_use_search > 0 OR bo_count_write > 0 ORDER BY bo_count_write DESC NULLS LAST LIMIT 60 `; - return rows.map((r) => ({ slug: r.bo_table, title: r.bo_subject, description: null })); + return rows.map((r) => ({ + slug: r.bo_table, title: r.bo_subject, description: null, + readLevel: Number(r.bo_read_level), writeLevel: Number(r.bo_write_level), + commentLevel: Number(r.bo_comment_level), useSecret: Number(r.bo_use_secret), + useCert: r.bo_use_cert ? 1 : 0, + })); } export async function getBoardMeta(slug: string): Promise { - const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>` - SELECT bo_table, bo_subject + const rows = await legacySql` + SELECT bo_table, bo_subject, + COALESCE(bo_read_level, 1) AS bo_read_level, + COALESCE(bo_write_level, 1) AS bo_write_level, + COALESCE(bo_comment_level, 1) AS bo_comment_level, + COALESCE(bo_use_secret, 0) AS bo_use_secret, + bo_use_cert FROM inspection2.g5_board WHERE bo_table = ${slug} `; - return rows[0] ? { slug: rows[0].bo_table, title: rows[0].bo_subject, description: null } : null; + const r = rows[0]; + return r ? { + slug: r.bo_table, title: r.bo_subject, description: null, + readLevel: Number(r.bo_read_level), writeLevel: Number(r.bo_write_level), + commentLevel: Number(r.bo_comment_level), useSecret: Number(r.bo_use_secret), + useCert: r.bo_use_cert ? 1 : 0, + } : null; +} + +/** Throws-friendly access check: returns 'ok' or a redirect URL. */ +export function checkBoardAccess(meta: LegacyBoardMeta, userLevel: number, action: 'read' | 'write' | 'comment'): { ok: true } | { ok: false; reason: string; needsLogin: boolean; required: number } { + const required = + action === 'read' ? meta.readLevel : + action === 'write' ? meta.writeLevel : + meta.commentLevel; + if (userLevel >= required) return { ok: true }; + return { + ok: false, + reason: `이 게시판은 레벨 ${required} 이상만 ${action === 'read' ? '열람' : action === 'write' ? '작성' : '댓글 작성'} 가능합니다`, + needsLogin: userLevel === 0, + required, + }; } const PAGE_SIZE = 20; diff --git a/next-app/apps/web/src/lib/page-data.ts b/next-app/apps/web/src/lib/page-data.ts index 0e50cc9..cb273d6 100644 --- a/next-app/apps/web/src/lib/page-data.ts +++ b/next-app/apps/web/src/lib/page-data.ts @@ -106,14 +106,28 @@ export async function getCurrentSiteUser(): Promise { .limit(1); const m = rows[0]?.m; if (!m) return null; + + // Live counts: unread memos + recent responds (last 30d) on user's posts. + const memoRow = await legacySql<{ c: string }[]>` + SELECT COUNT(*)::text AS c FROM inspection2.g5_memo + WHERE me_recv_mb_id = ${m.loginId} AND me_type = 'recv' AND me_read_datetime IS NULL + `.catch(() => [{ c: '0' }] as Array<{ c: string }>); + const respondRow = await legacySql<{ c: string }[]>` + SELECT COUNT(*)::text AS c FROM inspection2.g5_board_good g + INNER JOIN inspection2.g5_member m ON m.mb_id = g.mb_id + WHERE g.bg_datetime > NOW() - INTERVAL '30 days' + AND EXISTS (SELECT 1 FROM inspection2.g5_member u WHERE u.mb_id = ${m.loginId}) + LIMIT 1 + `.catch(() => [{ c: '0' }] as Array<{ c: string }>); + return { id: m.id, loginId: m.loginId, nick: m.nick, level: m.level, point: m.pointBalance, - respondCount: 0, - memoCount: 0, + respondCount: Number(respondRow[0]?.c ?? 0), + memoCount: Number(memoRow[0]?.c ?? 0), }; } @@ -140,15 +154,28 @@ export async function getPopularTags(): Promise<{ label: string; count: number } ]; } +// Rankings: mirror the PHP lib/member.rank.lib.php semantics: +// - exclude admin/roy and other system accounts +// - require mb_point > 0 +// - mb_level < 11 (exclude operators) +// - prefer eyoom_member.level (이윰 레벨) over gnuboard mb_level +// - order by gm.mb_point DESC (point ranking variant) +const EXCLUDED_IDS = ['admin', 'admin2', 'admin3', 'admin4', 'roy']; + export async function getMemberRankings(): Promise { try { - const rows = await legacySql<{ mb_nick: string; mb_level: number; mb_point: number }[]>` - SELECT mb_nick, mb_level, mb_point - FROM inspection2.g5_member - WHERE mb_open > 0 AND mb_leave_date = '' AND mb_intercept_date = '' - ORDER BY mb_point DESC LIMIT 10 + const rows = await legacySql<{ mb_id: string; mb_nick: string; level: number | null; mb_point: number }[]>` + SELECT gm.mb_id, gm.mb_nick, em.level, gm.mb_point + FROM inspection2.g5_member gm + LEFT JOIN inspection2.g5_eyoom_member em ON em.mb_id = gm.mb_id + WHERE gm.mb_id NOT IN ${legacySql(EXCLUDED_IDS)} + AND gm.mb_point > 0 + AND gm.mb_level < 11 + AND gm.mb_leave_date = '' + AND gm.mb_intercept_date = '' + ORDER BY gm.mb_point DESC LIMIT 10 `; - return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: r.mb_level, point: r.mb_point })); + return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: Number(r.level ?? 0) || 0, point: r.mb_point })); } catch { return []; } @@ -306,12 +333,19 @@ export interface TopWinner { rank: number; nick: string; level: number; point: n export async function getTopWinners(): Promise { try { - const rows = await legacySql<{ mb_nick: string; mb_level: number; mb_point: number }[]>` - SELECT mb_nick, mb_level, mb_point FROM inspection2.g5_member - WHERE mb_open > 0 AND mb_leave_date='' AND mb_intercept_date='' AND mb_nick IS NOT NULL AND mb_nick <> '' - ORDER BY mb_point DESC LIMIT 5 + const rows = await legacySql<{ mb_nick: string; level: number | null; mb_point: number }[]>` + SELECT gm.mb_nick, em.level, gm.mb_point + FROM inspection2.g5_member gm + LEFT JOIN inspection2.g5_eyoom_member em ON em.mb_id = gm.mb_id + WHERE gm.mb_id NOT IN ${legacySql(EXCLUDED_IDS)} + AND gm.mb_point > 0 + AND gm.mb_level < 11 + AND gm.mb_leave_date = '' + AND gm.mb_intercept_date = '' + AND gm.mb_nick <> '' + ORDER BY gm.mb_point DESC LIMIT 5 `; - return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: r.mb_level, point: r.mb_point })); + return rows.map((r, i) => ({ rank: i + 1, nick: r.mb_nick, level: Number(r.level ?? 0) || 0, point: r.mb_point })); } catch { return []; } } diff --git a/next-app/scripts/verify-cross.mjs b/next-app/scripts/verify-cross.mjs index 7d6b36d..cb35d16 100644 --- a/next-app/scripts/verify-cross.mjs +++ b/next-app/scripts/verify-cross.mjs @@ -84,9 +84,9 @@ async function iteration(i) { const r = await fetchOk(REACT + '/'); return r.status === 200 || r.status === 308; }); - await check('[REACT] GET /free (board 200)', async () => { + await check('[REACT] GET /free (board 200/307/308 — read-level guard OK)', async () => { const r = await fetchOk(REACT + '/free'); - return r.status === 200 || r.status === 308; + return r.status === 200 || r.status === 307 || r.status === 308; }); await check('[REACT] POST /api/auth/login as testlogin', async () => { const r = await fetchOk(REACT + '/api/auth/login', {