Board access guard, tag page real impl, rankings PHP-parity, freespin, more admin
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<article className="mx-auto max-w-md rounded-3xl bg-white p-6 text-center ring-1 ring-neutral-100">
|
||||
<div className="mx-auto mb-3 grid h-16 w-16 place-items-center rounded-full bg-rose-50 text-3xl">🔒</div>
|
||||
<h1 className="m-0 text-[20px] font-extrabold">{meta.title}</h1>
|
||||
<p className="mt-2 text-[13.5px] text-neutral-text-soft">{access.reason}</p>
|
||||
<p className="mt-1 text-[12px] text-neutral-500">현재 Lv.{user0?.level ?? 0} / 필요 Lv.{access.required}</p>
|
||||
<Link href="/" className="mt-5 inline-block rounded-full bg-brand-600 px-4 py-2 text-[12.5px] font-bold text-white">홈으로</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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 (
|
||||
<article className="mx-auto max-w-md rounded-3xl bg-white p-6 text-center shadow-[0_8px_24px_rgba(60,30,120,0.08)] ring-1 ring-neutral-100">
|
||||
<div className="mx-auto mb-3 grid h-16 w-16 place-items-center rounded-full bg-rose-50 text-3xl">🔒</div>
|
||||
<h1 className="m-0 text-[20px] font-extrabold">{meta.title}</h1>
|
||||
<p className="mt-2 text-[13.5px] text-neutral-text-soft">{access.reason}</p>
|
||||
<p className="mt-1 text-[12px] text-neutral-500">현재 회원 레벨: <strong>Lv.{userLevel}</strong> / 필요 레벨: <strong>Lv.{access.required}</strong></p>
|
||||
<div className="mt-5 flex justify-center gap-2">
|
||||
<Link href="/" className="rounded-full bg-neutral-100 px-4 py-2 text-[12.5px] font-bold text-neutral-700 hover:bg-neutral-200">홈</Link>
|
||||
<Link href="/mypage" className="rounded-full bg-brand-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-brand-700">내 정보</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, totalPages } = await listPosts(boardSlug, page);
|
||||
const h = await headers();
|
||||
const theme = await getThemeForPath(h.get('x-pathname') ?? `/${boardSlug}`);
|
||||
|
||||
@@ -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 <StubPage title={`${meta.title} — 글쓰기 불가`} lead={access.reason}>
|
||||
<p style={{ color: 'var(--color-textMuted)' }}>현재 Lv.{user!.level} / 필요 Lv.{access.required}</p>
|
||||
<a href={`/${boardSlug}`} style={{ display: 'inline-block', marginTop: 16, padding: '8px 18px', background: 'var(--color-primary)', color: '#fff', textDecoration: 'none', borderRadius: 4 }}>← 목록으로</a>
|
||||
</StubPage>;
|
||||
}
|
||||
return <StubPage title={`${meta.title} — 글쓰기`}>
|
||||
<form action={`/api/posts/create`} method="POST" style={{ background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', borderRadius: 8, padding: 20, display: 'grid', gap: 12 }}>
|
||||
<input type="hidden" name="boardSlug" value={boardSlug} />
|
||||
|
||||
@@ -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<Row[]>`
|
||||
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 (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">룰렛 · 복권</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">복권 당첨 내역 ({total.toLocaleString()})</h1>
|
||||
</header>
|
||||
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left">회원</th><th className="px-3 py-2 text-center">등수</th><th className="px-3 py-2 text-left">상품명</th><th className="px-3 py-2 text-right">포인트</th><th className="px-3 py-2 text-left">추첨일</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.lo_id} className="border-t border-neutral-100">
|
||||
<td className="px-3 py-2 font-mono">{r.lo_id}</td>
|
||||
<td className="px-3 py-2">{r.mb_id}</td>
|
||||
<td className="px-3 py-2 text-center"><span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${r.reward_rank === '1' ? 'bg-amber-100 text-amber-700' : r.reward_rank === '2' ? 'bg-zinc-100 text-zinc-700' : 'bg-orange-100 text-orange-700'}`}>{r.reward_rank}등</span></td>
|
||||
<td className="px-3 py-2">{r.reward_name}</td>
|
||||
<td className="px-3 py-2 text-right tabular font-bold">{r.reward_point > 0 ? `+${r.reward_point.toLocaleString()}p` : '-'}</td>
|
||||
<td className="px-3 py-2 text-[11px]">{r.lo_datetime && new Date(r.lo_datetime).toISOString().slice(0,10)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <tr><td colSpan={6} className="py-6 text-center text-[12px] text-neutral-text-soft">당첨 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-4 flex justify-center gap-1.5">
|
||||
{Array.from({ length: Math.min(totalPages, 10) }).map((_, i) => {
|
||||
const p = i + 1;
|
||||
return (
|
||||
<a key={p} href={`?page=${p}`} className={`rounded-md px-3 py-1.5 text-[12px] ${p === page ? 'bg-brand-600 text-white' : 'border border-neutral-200 bg-white'}`}>{p}</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<article>
|
||||
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">플러그인</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">reCAPTCHA 설정</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">Google reCAPTCHA v2/v3 키 저장. 가입/로그인/글쓰기 폼 보호.</p>
|
||||
</header>
|
||||
<form action={saveRecaptcha} className="grid max-w-xl gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
|
||||
<Field name="site_key" label="Site Key (공개 키)" defaultValue={cur.siteKey ?? ''} />
|
||||
<Field name="secret_key" label="Secret Key (서버 키)" defaultValue={cur.secretKey ?? ''} type="password" />
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-neutral-700">버전</label>
|
||||
<select name="version" defaultValue={cur.version ?? 'v3'} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]">
|
||||
<option value="v2">v2 (체크박스)</option>
|
||||
<option value="v3">v3 (보이지 않는 score 기반)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="enabled" defaultChecked={Boolean(cur.enabled)} /> 활성화</label>
|
||||
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[13px] font-bold text-white">저장</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ name, label, defaultValue, type = 'text' }: { name: string; label: string; defaultValue: string; type?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
|
||||
<input name={name} type={type} defaultValue={defaultValue} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px] font-mono" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Row[]>`
|
||||
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 (
|
||||
<article className="flex flex-col gap-5">
|
||||
<header className="border-b border-neutral-100 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">룰렛 · 복권</div>
|
||||
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">룰렛 회차 관리</h1>
|
||||
<p className="mt-1.5 text-[13px] text-neutral-text-soft">g5_roulette_list. 회차 제목 변경/삭제, 최근 30 당첨자 노출.</p>
|
||||
</header>
|
||||
<section>
|
||||
<h2 className="mb-2 text-[14px] font-bold text-neutral-700">회차 목록 ({rows.length})</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left">제목</th><th className="px-3 py-2 text-center">유형</th><th className="px-3 py-2 text-center">관리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.idx} className="border-t border-neutral-100">
|
||||
<td className="px-3 py-2 font-mono">{r.idx}</td>
|
||||
<td className="px-3 py-2">
|
||||
<form action={renameRoulette} className="flex items-center gap-1">
|
||||
<input type="hidden" name="idx" value={r.idx} />
|
||||
<input name="title" defaultValue={r.roul_title} className="flex-1 rounded border border-neutral-200 px-2 py-1 text-[12px]" />
|
||||
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white">저장</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-[11px]">{r.roul_type}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<form action={deleteRoulette} className="inline">
|
||||
<input type="hidden" name="idx" value={r.idx} />
|
||||
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <tr><td colSpan={4} className="py-6 text-center text-[12px] text-neutral-text-soft">등록된 룰렛 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="mb-2 text-[14px] font-bold text-neutral-700">최근 당첨자 (g5_roulette_reward_list, 30건)</h2>
|
||||
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
|
||||
{rewardRows.map((r) => (
|
||||
<li key={r.idx} className="flex items-center justify-between px-4 py-2 text-[12.5px]">
|
||||
<span><strong>{r.mb_id}</strong> 님이 <span className="rounded bg-amber-50 px-1.5 py-0.5 font-bold text-amber-700">{r.reward_name}</span> 당첨</span>
|
||||
<span className="flex gap-3 text-[11px] text-neutral-text-soft">
|
||||
{r.reward_point > 0 && <span className="font-bold text-emerald-700">+{Number(r.reward_point).toLocaleString()}p</span>}
|
||||
<span>{r.lo_datetime && new Date(r.lo_datetime).toLocaleDateString('ko-KR')}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rewardRows.length === 0 && <li className="px-4 py-6 text-center text-[12px] text-neutral-text-soft">기록 없음</li>}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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 <StubPage title={`#${decodeURIComponent(tag)}`} badge="준비중" lead="해당 태그가 달린 게시글을 모아 보여줍니다.">
|
||||
<p style={{ color: 'var(--color-textMuted)' }}>태그 인덱싱은 신규 posts 테이블 ETL 후 활성화됩니다.</p>
|
||||
</StubPage>;
|
||||
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<TagWriteRow[]>`
|
||||
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 (
|
||||
<article className="flex flex-col gap-5">
|
||||
<header className="rounded-3xl bg-gradient-to-br from-brand-700 via-fuchsia-700 to-pink-700 p-6 text-white shadow-[0_18px_38px_rgba(124,58,237,0.20)]">
|
||||
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">TAG</div>
|
||||
<h1 className="mt-1 inline-flex items-center gap-2 text-[28px] font-extrabold tracking-tight">
|
||||
<Hash size={22} /> {tag}
|
||||
</h1>
|
||||
<p className="mt-1 text-[12.5px] text-white/85">{total.toLocaleString()}건의 게시글 (페이지 {page}/{totalPages})</p>
|
||||
</header>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft">
|
||||
이 태그가 달린 글이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="m-0 grid divide-y divide-neutral-100 rounded-2xl border border-neutral-100 bg-white p-0 list-none">
|
||||
{rows.map((r) => {
|
||||
const tags = (r.wr_tag ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return (
|
||||
<li key={`${r.bo_table}-${r.wr_id}`} className="px-4 py-3 hover:bg-brand-50/40">
|
||||
<Link href={`/${r.bo_table}/${r.wr_id}`} className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700">/{r.bo_table}</span>
|
||||
<span className="ml-2 text-[14px] font-semibold text-neutral-800">{r.wr_subject}</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.slice(0, 5).map((t, i) => (
|
||||
<span key={i} className={`rounded-full px-1.5 py-0.5 text-[10px] ${t === tag ? 'bg-fuchsia-100 font-bold text-fuchsia-700' : 'bg-neutral-100 text-neutral-600'}`}>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3 text-[11px] text-neutral-text-soft">
|
||||
<span className="inline-flex items-center gap-1"><Eye size={11} /> {r.wr_hit ?? 0}</span>
|
||||
<span>{r.mb_nick}</span>
|
||||
<span>{r.tw_datetime ? new Date(r.tw_datetime).toLocaleDateString('ko-KR') : '-'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex justify-center gap-1.5">
|
||||
{page > 1 && <Link href={`?page=${page - 1}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">← 이전</Link>}
|
||||
<span className="rounded-md bg-brand-600 px-3 py-1.5 text-[12px] font-bold text-white">{page} / {totalPages}</span>
|
||||
{page < totalPages && <Link href={`?page=${page + 1}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-[12px] hover:bg-neutral-50">다음 →</Link>}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<Link href="/tags" className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700">← 모든 태그 보기</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<LegacyBoardMeta[]> {
|
||||
const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>`
|
||||
SELECT bo_table, bo_subject
|
||||
const rows = await legacySql<BoardRow[]>`
|
||||
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<LegacyBoardMeta | null> {
|
||||
const rows = await legacySql<{ bo_table: string; bo_subject: string }[]>`
|
||||
SELECT bo_table, bo_subject
|
||||
const rows = await legacySql<BoardRow[]>`
|
||||
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;
|
||||
|
||||
@@ -106,14 +106,28 @@ export async function getCurrentSiteUser(): Promise<SiteUser | null> {
|
||||
.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<RankingEntry[]> {
|
||||
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<TopWinner[]> {
|
||||
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 []; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user