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:
chpark
2026-04-28 21:05:20 +09:00
parent 782029d31f
commit 505398ec9f
11 changed files with 521 additions and 42 deletions
@@ -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)
+24 -2
View File
@@ -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>
);
}
+88 -6
View File
@@ -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>
);
}
+48 -8
View File
@@ -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 };
}
+53 -7
View File
@@ -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;
+47 -13
View File
@@ -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 []; }
}