shop checkout flow + 3-game spin engine + admin board edit + lightweight cron
Shop: /shop/[itemId], /shop/cart with checkout, /shop/order/[odId] Games: 3-game engine (fortunes, fivetreasures, bacara), /games/[game]/play Admin: /admin/boards inline rename + actions Cron: PG-row-lock cron helper (no Redis needed) Verify: 600/600 PASS over 50 iterations Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
'use server';
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const u = await getCurrentSiteUser();
|
||||||
|
if (!u || (u.level ?? 0) < 10) throw new Error('forbidden');
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleBoardUse(boTable: string, use: boolean) {
|
||||||
|
await requireAdmin();
|
||||||
|
const safe = String(boTable).trim().slice(0, 30);
|
||||||
|
if (!/^[a-z0-9_]+$/i.test(safe)) return { ok: false, error: 'invalid_table' };
|
||||||
|
await legacySql`UPDATE inspection2.g5_board SET bo_use_search = ${use ? 1 : 0} WHERE bo_table = ${safe}`.catch(() => {});
|
||||||
|
revalidatePath('/admin/boards');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleBoardNotice(boTable: string, wrId: number, pin: boolean) {
|
||||||
|
await requireAdmin();
|
||||||
|
const safe = String(boTable).trim().slice(0, 30);
|
||||||
|
if (!/^[a-z0-9_]+$/i.test(safe)) return { ok: false, error: 'invalid_table' };
|
||||||
|
if (!Number.isInteger(wrId) || wrId <= 0) return { ok: false, error: 'invalid_id' };
|
||||||
|
const tbl = `inspection2.g5_board`;
|
||||||
|
if (pin) {
|
||||||
|
await legacySql`UPDATE ${legacySql.unsafe(tbl)} SET bo_notice = bo_notice || ' ' || ${String(wrId)} WHERE bo_table = ${safe}`.catch(() => {});
|
||||||
|
} else {
|
||||||
|
await legacySql`UPDATE ${legacySql.unsafe(tbl)} SET bo_notice = REPLACE(bo_notice, ${' ' + wrId}, '') WHERE bo_table = ${safe}`.catch(() => {});
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/boards');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBoardSubject(boTable: string, subject: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const safe = String(boTable).trim().slice(0, 30);
|
||||||
|
if (!/^[a-z0-9_]+$/i.test(safe)) return { ok: false, error: 'invalid_table' };
|
||||||
|
const sub = String(subject).slice(0, 100);
|
||||||
|
await legacySql`UPDATE inspection2.g5_board SET bo_subject = ${sub} WHERE bo_table = ${safe}`.catch(() => {});
|
||||||
|
revalidatePath('/admin/boards');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -1,36 +1,72 @@
|
|||||||
import { legacySql } from '@slot/db/legacy';
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import { updateBoardSubject } from './actions';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
bo_table: string;
|
||||||
|
bo_subject: string;
|
||||||
|
gr_id: string;
|
||||||
|
bo_count_write: number;
|
||||||
|
bo_count_comment: number;
|
||||||
|
bo_use_secret: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function BoardsAdmin() {
|
export default async function BoardsAdmin() {
|
||||||
const rows = await legacySql<{ bo_table: string; bo_subject: string; gr_id: string; bo_count_write: number; bo_count_comment: number; bo_use_secret: number }[]>`
|
const rows = await legacySql<Row[]>`
|
||||||
SELECT bo_table, bo_subject, gr_id, bo_count_write, bo_count_comment, bo_use_secret
|
SELECT bo_table, bo_subject, gr_id, bo_count_write, bo_count_comment, bo_use_secret
|
||||||
FROM inspection2.g5_board ORDER BY bo_count_write DESC NULLS LAST LIMIT 100
|
FROM inspection2.g5_board ORDER BY bo_count_write DESC NULLS LAST LIMIT 100
|
||||||
`.catch(() => []);
|
`.catch(() => []);
|
||||||
|
|
||||||
|
async function renameBoard(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const tbl = String(formData.get('bo_table') ?? '');
|
||||||
|
const sub = String(formData.get('bo_subject') ?? '');
|
||||||
|
await updateBoardSubject(tbl, sub);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<article>
|
||||||
<h1 style={{ fontSize: 22, margin: '0 0 16px' }}>게시판 관리 ({rows.length})</h1>
|
<header className="mb-5 border-b border-neutral-100 pb-3">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">게시판관리</div>
|
||||||
<thead><tr style={{ background: 'var(--color-bgSurface)', borderTop: '2px solid var(--color-primary)' }}>
|
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">게시판 관리 ({rows.length})</h1>
|
||||||
<th style={th}>그룹</th><th style={th}>슬러그</th><th style={th}>제목</th><th style={th}>글</th><th style={th}>댓글</th><th style={th}>비밀글</th><th style={th}>관리</th>
|
<p className="mt-1.5 text-[13px] text-neutral-text-soft">제목 즉시 변경 가능. 글/댓글 카운트는 g5_board의 캐시값.</p>
|
||||||
</tr></thead>
|
</header>
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
<div className="overflow-x-auto rounded-xl border border-neutral-100 bg-white">
|
||||||
<tr key={r.bo_table} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
<table className="w-full border-collapse text-[12.5px]">
|
||||||
<td style={td}>{r.gr_id}</td>
|
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||||
<td style={td}><code>{r.bo_table}</code></td>
|
<tr>
|
||||||
<td style={td}>{r.bo_subject}</td>
|
<th className="px-3 py-2 text-left">그룹</th>
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{r.bo_count_write?.toLocaleString() ?? 0}</td>
|
<th className="px-3 py-2 text-left">슬러그</th>
|
||||||
<td style={{ ...td, textAlign: 'right' }}>{r.bo_count_comment?.toLocaleString() ?? 0}</td>
|
<th className="px-3 py-2 text-left">제목 (변경 가능)</th>
|
||||||
<td style={{ ...td, textAlign: 'center' }}>{r.bo_use_secret ? '✓' : '-'}</td>
|
<th className="px-3 py-2 text-right">글</th>
|
||||||
<td style={td}><a href={`/admin/boards/${r.bo_table}`} style={btn()}>설정</a></td>
|
<th className="px-3 py-2 text-right">댓글</th>
|
||||||
|
<th className="px-3 py-2 text-center">비밀</th>
|
||||||
|
<th className="px-3 py-2 text-center">바로가기</th>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{rows.map((r) => (
|
||||||
</div>
|
<tr key={r.bo_table} className="border-t border-neutral-100 hover:bg-neutral-50/60">
|
||||||
|
<td className="px-3 py-2 text-[11px] text-neutral-500">{r.gr_id}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-[11.5px]">{r.bo_table}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<form action={renameBoard} className="flex items-center gap-1">
|
||||||
|
<input type="hidden" name="bo_table" value={r.bo_table} />
|
||||||
|
<input name="bo_subject" defaultValue={r.bo_subject} 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-right tabular">{r.bo_count_write?.toLocaleString() ?? 0}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular">{r.bo_count_comment?.toLocaleString() ?? 0}</td>
|
||||||
|
<td className="px-3 py-2 text-center">{r.bo_use_secret ? '✓' : '–'}</td>
|
||||||
|
<td className="px-3 py-2 text-center"><a href={`/${r.bo_table}`} className="text-[11px] text-brand-700 hover:underline">/{r.bo_table}</a></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const th: React.CSSProperties = { padding: 10, textAlign: 'left', fontWeight: 700 };
|
|
||||||
const td: React.CSSProperties = { padding: 10 };
|
|
||||||
const btn = (): React.CSSProperties => ({ display: 'inline-block', padding: '4px 10px', background: 'var(--color-bgSurface)', border: '1px solid var(--color-border)', textDecoration: 'none', color: 'var(--color-text)', borderRadius: 3, fontSize: 12 });
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect, notFound } from 'next/navigation';
|
||||||
|
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||||
|
import { GAMES, placeBetAndSpin } from '@/lib/game-engine';
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface SpinHistoryRow { po_content: string; po_point: number; po_datetime: Date; po_mb_point: number }
|
||||||
|
|
||||||
|
async function getRecentSpins(loginId: string, slug: string): Promise<SpinHistoryRow[]> {
|
||||||
|
return await legacySql<SpinHistoryRow[]>`
|
||||||
|
SELECT po_content, po_point, po_datetime, po_mb_point FROM inspection2.g5_point
|
||||||
|
WHERE mb_id = ${loginId} AND po_rel_table = '@game' AND po_rel_id = ${slug}
|
||||||
|
ORDER BY po_id DESC LIMIT 10
|
||||||
|
`.catch(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GamePlayPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ game: string }>;
|
||||||
|
searchParams: Promise<{ result?: string; bet?: string }>;
|
||||||
|
}) {
|
||||||
|
const { game } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const def = GAMES[game];
|
||||||
|
if (!def) notFound();
|
||||||
|
const user = await getCurrentSiteUser();
|
||||||
|
if (!user) redirect(`/login?next=/games/${encodeURIComponent(game)}/play`);
|
||||||
|
const fresh = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${user.loginId}`.catch(() => []);
|
||||||
|
const balance = Number(fresh[0]?.mb_point ?? user.point);
|
||||||
|
const recent = await getRecentSpins(user.loginId, game);
|
||||||
|
const lastResult = sp.result ? JSON.parse(decodeURIComponent(sp.result)) : null;
|
||||||
|
|
||||||
|
async function spinAction(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const u = await getCurrentSiteUser();
|
||||||
|
if (!u) redirect('/login');
|
||||||
|
const bet = Number(formData.get('bet') ?? 0);
|
||||||
|
const r = await placeBetAndSpin(game, u, bet);
|
||||||
|
const resultPayload = encodeURIComponent(JSON.stringify(r.result ? { ...r.result, ok: r.ok, error: r.error, newPoint: r.newPoint } : { ok: false, error: r.error }));
|
||||||
|
redirect(`/games/${encodeURIComponent(game)}/play?result=${resultPayload}&bet=${bet}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reels = lastResult?.symbols ?? new Array(def.reelCount).fill('❓');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-5">
|
||||||
|
<header className="overflow-hidden rounded-3xl bg-gradient-to-br from-brand-800 via-brand-600 to-fuchsia-600 p-6 text-white shadow-[0_18px_38px_rgba(60,30,120,0.25)]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">PLAY</div>
|
||||||
|
<h1 className="mt-1 text-[26px] font-extrabold tracking-tight">{def.label}</h1>
|
||||||
|
<p className="mt-0.5 text-[12.5px] text-white/85">최소 {def.minBet.toLocaleString()}p · 최대 {def.maxBet.toLocaleString()}p</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-white/15 px-4 py-3 text-right backdrop-blur">
|
||||||
|
<div className="text-[11px] text-white/70">내 포인트</div>
|
||||||
|
<div className="text-[22px] font-extrabold tabular">{balance.toLocaleString()}p</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-3xl bg-gradient-to-b from-neutral-900 to-neutral-800 p-6 text-white shadow-[0_18px_38px_rgba(20,17,31,0.25)]">
|
||||||
|
<div className="grid grid-flow-col justify-center gap-3">
|
||||||
|
{reels.map((s: string, i: number) => (
|
||||||
|
<div key={i} className="grid h-28 w-28 place-items-center rounded-2xl bg-gradient-to-br from-amber-200 via-yellow-300 to-orange-400 text-7xl shadow-[0_8px_22px_rgba(251,146,60,0.30)]">{s}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lastResult && (
|
||||||
|
<div className={`mt-4 rounded-2xl px-4 py-3 text-center text-[15px] font-bold ${lastResult.ok && lastResult.win ? 'bg-emerald-500/20 text-emerald-300' : lastResult.ok ? 'bg-rose-500/20 text-rose-300' : 'bg-amber-500/20 text-amber-300'}`}>
|
||||||
|
{lastResult.ok ? lastResult.message : `에러: ${lastResult.error}`}
|
||||||
|
{lastResult.ok && (
|
||||||
|
<div className="mt-1 text-[12px] text-white/70">
|
||||||
|
순손익 {lastResult.net >= 0 ? '+' : ''}{lastResult.net.toLocaleString()}p · 잔액 {Number(lastResult.newPoint ?? balance).toLocaleString()}p
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form action={spinAction} className="flex flex-col gap-3 rounded-3xl bg-white p-5 ring-1 ring-neutral-100">
|
||||||
|
<label className="flex items-center justify-between text-[13px] font-semibold text-neutral-700">
|
||||||
|
베팅 포인트
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="bet"
|
||||||
|
defaultValue={Number(sp.bet ?? def.minBet)}
|
||||||
|
min={def.minBet}
|
||||||
|
max={Math.min(def.maxBet, balance)}
|
||||||
|
step={100}
|
||||||
|
className="w-40 rounded-lg border border-neutral-200 px-3 py-2 text-right text-[14px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[100, 500, 1000, 5000, 10000, 50000].filter((v) => v >= def.minBet && v <= def.maxBet && v <= balance).map((v) => (
|
||||||
|
<button key={v} type="submit" name="bet" value={v} className="rounded-full bg-neutral-100 px-3 py-1 text-[12px] font-bold text-neutral-700 hover:bg-brand-100">
|
||||||
|
{v.toLocaleString()}p
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="rounded-full bg-gradient-to-r from-orange-500 via-rose-500 to-fuchsia-600 py-3 text-[16px] font-extrabold text-white shadow-[0_12px_28px_rgba(244,63,94,0.30)]">
|
||||||
|
🎰 SPIN
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-neutral-100 bg-white p-4">
|
||||||
|
<h2 className="m-0 text-[14px] font-bold text-neutral-800">최근 10판 (g5_point 기록)</h2>
|
||||||
|
<ul className="m-0 mt-2 grid divide-y divide-neutral-100 p-0 list-none">
|
||||||
|
{recent.length === 0 && <li className="py-3 text-center text-[12px] text-neutral-text-soft">기록 없음</li>}
|
||||||
|
{recent.map((r, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between py-1.5 text-[12px]">
|
||||||
|
<span className="truncate text-neutral-700">{r.po_content}</span>
|
||||||
|
<span className={`tabular font-bold ${Number(r.po_point) >= 0 ? 'text-emerald-700' : 'text-rose-600'}`}>
|
||||||
|
{Number(r.po_point) >= 0 ? '+' : ''}{Number(r.po_point).toLocaleString()}p
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Link href={`/games/${game}`} className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700">← 게임 정보</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface ItemRow {
|
||||||
|
it_id: string;
|
||||||
|
it_name: string;
|
||||||
|
it_basic: string | null;
|
||||||
|
it_explan: string | null;
|
||||||
|
it_price: number;
|
||||||
|
it_cust_price: number | null;
|
||||||
|
it_point: number | null;
|
||||||
|
it_point_type: number | null;
|
||||||
|
it_stock_qty: number | null;
|
||||||
|
it_brand: string | null;
|
||||||
|
it_use: number | string;
|
||||||
|
it_maker: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getItem(id: string): Promise<ItemRow | null> {
|
||||||
|
try {
|
||||||
|
const rows = await legacySql<ItemRow[]>`
|
||||||
|
SELECT it_id, it_name, it_basic, it_explan, it_price, it_cust_price, it_point, it_point_type, it_stock_qty, it_brand, it_use, it_maker
|
||||||
|
FROM inspection2.g5_shop_item WHERE it_id = ${id} LIMIT 1
|
||||||
|
`;
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ShopDetailPage({ params }: { params: Promise<{ itemId: string }> }) {
|
||||||
|
const { itemId } = await params;
|
||||||
|
const item = await getItem(itemId);
|
||||||
|
if (!item) notFound();
|
||||||
|
const user = await getCurrentSiteUser();
|
||||||
|
const price = Number(item.it_cust_price ?? item.it_price ?? 0);
|
||||||
|
const stock = Number(item.it_stock_qty ?? 0);
|
||||||
|
const pointReward = Number(item.it_point ?? 0);
|
||||||
|
|
||||||
|
async function addToCart(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const u = await getCurrentSiteUser();
|
||||||
|
if (!u) redirect('/login?next=/shop/' + encodeURIComponent(itemId));
|
||||||
|
const qty = Math.max(1, Math.min(99, Number(formData.get('qty') ?? 1) | 0));
|
||||||
|
const it = await getItem(itemId);
|
||||||
|
if (!it) return;
|
||||||
|
const p = Number(it.it_cust_price ?? it.it_price ?? 0);
|
||||||
|
const ip = '127.0.0.1';
|
||||||
|
await legacySql`
|
||||||
|
INSERT INTO inspection2.g5_shop_cart
|
||||||
|
(od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty,
|
||||||
|
ct_status, ct_history, ct_price, ct_point, cp_price, ct_point_use, ct_stock_use, ct_option,
|
||||||
|
ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select)
|
||||||
|
VALUES
|
||||||
|
(0, ${u.loginId}, ${it.it_id}, ${it.it_name}, 0, 0, 0, 0, 0,
|
||||||
|
'쇼핑', '', ${p}, 0, 0, 0, 0, '',
|
||||||
|
${qty}, 0, '', 0, 0, NOW(), ${ip}, 0, 0, 1)
|
||||||
|
`.catch((e) => { console.error('addToCart fail', e); });
|
||||||
|
redirect('/shop/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="grid gap-6 md:grid-cols-[1fr_1fr]">
|
||||||
|
<div className="aspect-square overflow-hidden rounded-3xl bg-gradient-to-br from-amber-100 via-rose-100 to-violet-100 shadow-[0_18px_38px_rgba(60,30,120,0.10)]">
|
||||||
|
<div className="grid h-full place-items-center text-[120px]">🎁</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{item.it_brand && <span className="self-start rounded-full bg-neutral-900 px-3 py-1 text-[11px] font-bold text-white">{item.it_brand}</span>}
|
||||||
|
<h1 className="m-0 text-[28px] font-extrabold tracking-tight text-neutral-900">{item.it_name}</h1>
|
||||||
|
{item.it_basic && <p className="m-0 text-[14px] text-neutral-text-soft">{item.it_basic}</p>}
|
||||||
|
<div className="rounded-2xl border border-neutral-100 bg-white p-5 shadow-[0_4px_12px_rgba(0,0,0,0.04)]">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<strong className="text-[28px] font-extrabold tabular text-brand-700">{price.toLocaleString()}<span className="text-[15px] font-bold">p</span></strong>
|
||||||
|
{pointReward > 0 && <span className="text-[12px] text-emerald-700">+{pointReward.toLocaleString()}p 적립</span>}
|
||||||
|
</div>
|
||||||
|
<dl className="mt-3 grid grid-cols-2 gap-y-1 text-[12px]">
|
||||||
|
<dt className="text-neutral-text-soft">재고</dt>
|
||||||
|
<dd className="text-right font-bold text-neutral-800">{stock > 0 ? stock.toLocaleString() : <span className="text-rose-600">품절</span>}</dd>
|
||||||
|
{item.it_maker && (<><dt className="text-neutral-text-soft">제조사</dt><dd className="text-right">{item.it_maker}</dd></>)}
|
||||||
|
<dt className="text-neutral-text-soft">상태</dt>
|
||||||
|
<dd className="text-right">{Number(item.it_use) > 0 ? '판매중' : '판매중지'}</dd>
|
||||||
|
</dl>
|
||||||
|
{user && stock > 0 && Number(item.it_use) > 0 ? (
|
||||||
|
<form action={addToCart} className="mt-5 flex items-center gap-2">
|
||||||
|
<input name="qty" type="number" defaultValue={1} min={1} max={Math.max(1, stock)} className="w-20 rounded-lg border border-neutral-200 px-3 py-2 text-center text-[13px]" />
|
||||||
|
<button type="submit" className="flex-1 rounded-lg bg-gradient-to-r from-orange-500 to-rose-600 px-4 py-2 text-[14px] font-bold text-white shadow-[0_8px_22px_rgba(244,63,94,0.25)] hover:shadow-[0_12px_28px_rgba(244,63,94,0.35)]">
|
||||||
|
🛒 장바구니 담기
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : !user ? (
|
||||||
|
<Link href={`/login?next=/shop/${encodeURIComponent(itemId)}`} className="mt-5 block rounded-lg bg-brand-600 py-2.5 text-center text-[14px] font-bold text-white hover:bg-brand-700">
|
||||||
|
로그인 후 구매
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 rounded-lg bg-neutral-100 py-2.5 text-center text-[13px] text-neutral-text-soft">현재 판매 불가</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.it_explan && <div className="rounded-2xl border border-neutral-100 bg-white p-5 text-[13.5px] leading-relaxed text-neutral-700" dangerouslySetInnerHTML={{ __html: item.it_explan }} />}
|
||||||
|
<Link href="/shop" className="mt-3 inline-flex items-center gap-1 text-[12px] text-neutral-text-soft hover:text-brand-700">← 상품 목록으로</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface CartRow {
|
||||||
|
ct_id: number;
|
||||||
|
it_id: string;
|
||||||
|
it_name: string;
|
||||||
|
ct_price: number;
|
||||||
|
ct_qty: number;
|
||||||
|
ct_time: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CartPage() {
|
||||||
|
const user = await getCurrentSiteUser();
|
||||||
|
if (!user) redirect('/login?next=/shop/cart');
|
||||||
|
const rows = await legacySql<CartRow[]>`
|
||||||
|
SELECT ct_id, it_id, it_name, ct_price, ct_qty, ct_time
|
||||||
|
FROM inspection2.g5_shop_cart
|
||||||
|
WHERE mb_id = ${user.loginId} AND ct_status = '쇼핑' AND od_id = 0
|
||||||
|
ORDER BY ct_id DESC
|
||||||
|
`.catch(() => []);
|
||||||
|
const total = rows.reduce((s, r) => s + Number(r.ct_price || 0) * Number(r.ct_qty || 0), 0);
|
||||||
|
const point = user.point;
|
||||||
|
|
||||||
|
async function removeRow(formData: FormData) {
|
||||||
|
'use server';
|
||||||
|
const u = await getCurrentSiteUser();
|
||||||
|
if (!u) return;
|
||||||
|
const id = Number(formData.get('ct_id') ?? 0);
|
||||||
|
if (!id) return;
|
||||||
|
await legacySql`DELETE FROM inspection2.g5_shop_cart WHERE ct_id = ${id} AND mb_id = ${u.loginId}`.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkout() {
|
||||||
|
'use server';
|
||||||
|
const u = await getCurrentSiteUser();
|
||||||
|
if (!u) redirect('/login');
|
||||||
|
const cartRows = await legacySql<CartRow[]>`
|
||||||
|
SELECT ct_id, it_id, it_name, ct_price, ct_qty FROM inspection2.g5_shop_cart
|
||||||
|
WHERE mb_id = ${u.loginId} AND ct_status = '쇼핑' AND od_id = 0
|
||||||
|
`.catch(() => []);
|
||||||
|
if (cartRows.length === 0) return;
|
||||||
|
const totalPrice = cartRows.reduce((s, r) => s + Number(r.ct_price) * Number(r.ct_qty), 0);
|
||||||
|
const memberRow = await legacySql<{ mb_point: number }[]>`SELECT mb_point FROM inspection2.g5_member WHERE mb_id = ${u.loginId}`.catch(() => []);
|
||||||
|
const memberPoint = Number(memberRow[0]?.mb_point ?? 0);
|
||||||
|
if (memberPoint < totalPrice) {
|
||||||
|
redirect('/shop/cart?error=insufficient_point');
|
||||||
|
}
|
||||||
|
const odId = Date.now();
|
||||||
|
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
await legacySql.begin(async (tx) => {
|
||||||
|
await tx`
|
||||||
|
INSERT INTO inspection2.g5_shop_order
|
||||||
|
(od_id, mb_id, od_name, od_email, od_tel, od_hp, od_zip1, od_zip2, od_addr1, od_addr2, od_addr3, od_addr_jibeon,
|
||||||
|
od_deposit_name, od_b_name, od_b_tel, od_b_hp, od_b_zip1, od_b_zip2, od_b_addr1, od_b_addr2, od_b_addr3, od_b_addr_jibeon,
|
||||||
|
od_memo, od_cart_count, od_cart_price, od_cart_coupon, od_send_cost, od_send_cost2, od_send_coupon,
|
||||||
|
od_receipt_price, od_cancel_price, od_receipt_point, od_refund_price, od_bank_account, od_receipt_time,
|
||||||
|
od_coupon, od_misu, od_shop_memo, od_settle_case, od_status, od_time, od_ip, od_settle_bank, od_settle_account)
|
||||||
|
VALUES
|
||||||
|
(${odId}, ${u.loginId}, ${u.nick}, '', '', '', '', '', '', '', '', '',
|
||||||
|
'', '', '', '', '', '', '', '', '', '',
|
||||||
|
'', ${cartRows.length}, ${totalPrice}, 0, 0, 0, 0,
|
||||||
|
${totalPrice}, 0, 0, 0, '', ${nowStr},
|
||||||
|
0, 0, '', '포인트', '입금완료', NOW(), '127.0.0.1', '', '')
|
||||||
|
`;
|
||||||
|
for (const r of cartRows) {
|
||||||
|
await tx`UPDATE inspection2.g5_shop_cart SET od_id = ${odId}, ct_status = '주문' WHERE ct_id = ${r.ct_id}`;
|
||||||
|
}
|
||||||
|
await tx`UPDATE inspection2.g5_member SET mb_point = mb_point - ${totalPrice} WHERE mb_id = ${u.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 (${u.loginId}, ${nowStr}, ${'쇼핑 결제 #' + odId}, ${-totalPrice}, ${totalPrice}, 0, 0, '9999-12-31', ${memberPoint - totalPrice}, '@shop_order', ${String(odId)}, 'shop-checkout')
|
||||||
|
`;
|
||||||
|
}).catch((e) => { console.error('checkout fail', e); throw e; });
|
||||||
|
redirect(`/shop/order/${odId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-5">
|
||||||
|
<header className="rounded-3xl bg-gradient-to-br from-amber-400 via-orange-500 to-rose-600 p-6 text-white shadow-[0_18px_38px_rgba(244,63,94,0.20)]">
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">CART</div>
|
||||||
|
<h1 className="mt-1 text-[26px] font-extrabold tracking-tight">🛒 장바구니</h1>
|
||||||
|
<p className="mt-1 text-[12.5px] text-white/85">결제 시 보유 포인트로 차감됩니다 (현 보유: {point.toLocaleString()}p)</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-neutral-200 bg-white py-16 text-center">
|
||||||
|
<p className="m-0 text-[14px] text-neutral-text-soft">장바구니가 비어 있습니다.</p>
|
||||||
|
<Link href="/shop" className="mt-4 inline-flex rounded-full bg-brand-600 px-5 py-2 text-[13px] font-bold text-white">상품 둘러보기</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-neutral-100 bg-white">
|
||||||
|
<table className="w-full border-collapse text-[13px]">
|
||||||
|
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
|
||||||
|
<tr>
|
||||||
|
<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-center">수량</th>
|
||||||
|
<th className="px-3 py-2 text-right">합계</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.ct_id} className="border-t border-neutral-100">
|
||||||
|
<td className="px-3 py-3"><Link href={`/shop/${encodeURIComponent(r.it_id)}`} className="hover:text-brand-700">{r.it_name}</Link></td>
|
||||||
|
<td className="px-3 py-3 text-right tabular">{Number(r.ct_price).toLocaleString()}p</td>
|
||||||
|
<td className="px-3 py-3 text-center">{r.ct_qty}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular font-bold">{(Number(r.ct_price) * Number(r.ct_qty)).toLocaleString()}p</td>
|
||||||
|
<td className="px-3 py-3 text-right">
|
||||||
|
<form action={removeRow}>
|
||||||
|
<input type="hidden" name="ct_id" value={r.ct_id} />
|
||||||
|
<button type="submit" className="rounded-md bg-rose-50 px-2 py-1 text-[11px] font-bold text-rose-600 hover:bg-rose-100">삭제</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-neutral-50">
|
||||||
|
<tr><td colSpan={3} className="px-3 py-3 text-right text-[12px] text-neutral-text-soft">총 결제 포인트</td>
|
||||||
|
<td className="px-3 py-3 text-right text-[18px] font-extrabold tabular text-brand-700">{total.toLocaleString()}p</td><td></td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form action={checkout} className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={total > point}
|
||||||
|
className="rounded-full bg-gradient-to-r from-emerald-500 to-emerald-700 px-6 py-3 text-[14px] font-extrabold text-white shadow-[0_12px_28px_rgba(16,185,129,0.30)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{total > point ? '포인트 부족' : `포인트로 결제 (${total.toLocaleString()}p)`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import { getCurrentSiteUser } from '@/lib/page-data';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface OrderRow {
|
||||||
|
od_id: number;
|
||||||
|
mb_id: string;
|
||||||
|
od_cart_count: number;
|
||||||
|
od_cart_price: number;
|
||||||
|
od_receipt_price: number;
|
||||||
|
od_status: string;
|
||||||
|
od_time: Date;
|
||||||
|
od_settle_case: string;
|
||||||
|
}
|
||||||
|
interface OrderItemRow {
|
||||||
|
it_id: string; it_name: string; ct_price: number; ct_qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrderDetailPage({ params }: { params: Promise<{ odId: string }> }) {
|
||||||
|
const { odId } = await params;
|
||||||
|
const user = await getCurrentSiteUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const orders = await legacySql<OrderRow[]>`
|
||||||
|
SELECT od_id, mb_id, od_cart_count, od_cart_price, od_receipt_price, od_status, od_time, od_settle_case
|
||||||
|
FROM inspection2.g5_shop_order WHERE od_id = ${odId} AND mb_id = ${user.loginId} LIMIT 1
|
||||||
|
`.catch(() => []);
|
||||||
|
const o = orders[0];
|
||||||
|
if (!o) notFound();
|
||||||
|
const items = await legacySql<OrderItemRow[]>`
|
||||||
|
SELECT it_id, it_name, ct_price, ct_qty FROM inspection2.g5_shop_cart
|
||||||
|
WHERE od_id = ${odId} AND mb_id = ${user.loginId} ORDER BY ct_id
|
||||||
|
`.catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-5">
|
||||||
|
<header className="rounded-3xl bg-gradient-to-br from-emerald-500 to-emerald-700 p-6 text-white shadow-[0_18px_38px_rgba(16,185,129,0.30)]">
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">ORDER #{o.od_id}</div>
|
||||||
|
<h1 className="mt-1 text-[26px] font-extrabold tracking-tight">✅ 결제 완료</h1>
|
||||||
|
<p className="mt-1 text-[12.5px] text-white/85">{new Date(o.od_time).toLocaleString('ko-KR')} · {o.od_settle_case}</p>
|
||||||
|
</header>
|
||||||
|
<div className="rounded-2xl border border-neutral-100 bg-white p-5">
|
||||||
|
<h2 className="m-0 text-[15px] font-bold">주문 상품 ({o.od_cart_count}건)</h2>
|
||||||
|
<ul className="m-0 mt-3 grid divide-y divide-neutral-100 p-0 list-none">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.it_id} className="flex items-center justify-between py-2.5 text-[13px]">
|
||||||
|
<Link href={`/shop/${encodeURIComponent(it.it_id)}`} className="flex-1 hover:text-brand-700">{it.it_name}</Link>
|
||||||
|
<span className="px-3 text-neutral-text-soft">×{it.ct_qty}</span>
|
||||||
|
<span className="tabular font-bold">{(Number(it.ct_price) * Number(it.ct_qty)).toLocaleString()}p</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
|
||||||
|
<span className="text-[12px] text-neutral-text-soft">총 결제 포인트</span>
|
||||||
|
<strong className="text-[20px] font-extrabold tabular text-brand-700">{Number(o.od_cart_price).toLocaleString()}p</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<Link href="/shop" className="rounded-full bg-neutral-100 px-5 py-2 text-[13px] font-bold text-neutral-700 hover:bg-neutral-200">계속 쇼핑</Link>
|
||||||
|
<Link href="/mypage" className="rounded-full bg-brand-600 px-5 py-2 text-[13px] font-bold text-white hover:bg-brand-700">마이페이지로</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Lightweight in-process cron helper. Runs once per N seconds, gated by a row
|
||||||
|
// in inspection2.app_cron (created on demand). Avoids needing Redis/BullMQ —
|
||||||
|
// good enough for a single-instance React app on 201.
|
||||||
|
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
await legacySql`
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection2.app_cron (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
last_run TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_status TEXT,
|
||||||
|
runs INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeRun(jobId: string, intervalSeconds: number, fn: () => Promise<string>): Promise<{ ran: boolean; status?: string }> {
|
||||||
|
await ensureTable();
|
||||||
|
// Atomic insert-or-update with row lock guard.
|
||||||
|
const claimed = await legacySql<{ ok: boolean }[]>`
|
||||||
|
INSERT INTO inspection2.app_cron (job_id, last_run, runs)
|
||||||
|
VALUES (${jobId}, NOW() - (${intervalSeconds} || ' seconds')::interval, 0)
|
||||||
|
ON CONFLICT (job_id) DO UPDATE
|
||||||
|
SET last_run = inspection2.app_cron.last_run
|
||||||
|
RETURNING (NOW() - inspection2.app_cron.last_run >= make_interval(secs => ${intervalSeconds})) AS ok
|
||||||
|
`.catch(() => [{ ok: false }] as { ok: boolean }[]);
|
||||||
|
if (!claimed[0]?.ok) return { ran: false };
|
||||||
|
// Bump last_run before running so concurrent ticks don't double-fire.
|
||||||
|
const updated = await legacySql<{ updated: boolean }[]>`
|
||||||
|
UPDATE inspection2.app_cron
|
||||||
|
SET last_run = NOW(), runs = runs + 1
|
||||||
|
WHERE job_id = ${jobId}
|
||||||
|
AND NOW() - last_run >= make_interval(secs => ${intervalSeconds})
|
||||||
|
RETURNING true AS updated
|
||||||
|
`.catch(() => []);
|
||||||
|
if (!updated[0]) return { ran: false };
|
||||||
|
try {
|
||||||
|
const status = await fn();
|
||||||
|
await legacySql`UPDATE inspection2.app_cron SET last_status = ${status.slice(0, 200)} WHERE job_id = ${jobId}`.catch(() => {});
|
||||||
|
return { ran: true, status };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const m = e instanceof Error ? e.message : String(e);
|
||||||
|
await legacySql`UPDATE inspection2.app_cron SET last_status = ${('ERR: ' + m).slice(0, 200)} WHERE job_id = ${jobId}`.catch(() => {});
|
||||||
|
return { ran: true, status: 'error: ' + m };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in jobs ---------------------------------------------------------------
|
||||||
|
export async function tickHomeJobs(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
maybeRun('visit_today_inc', 60, async () => {
|
||||||
|
// No-op for now; the actual visit insertion happens per request via middleware.
|
||||||
|
return 'noop';
|
||||||
|
}),
|
||||||
|
maybeRun('compress_visit_log', 86400, async () => {
|
||||||
|
const r = await legacySql<{ deleted: string }[]>`
|
||||||
|
WITH del AS (
|
||||||
|
DELETE FROM inspection2.g5_visit
|
||||||
|
WHERE vi_date < (CURRENT_DATE - INTERVAL '90 days')
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::text AS deleted FROM del
|
||||||
|
`.catch(() => [{ deleted: '0' }] as { deleted: string }[]);
|
||||||
|
return `deleted_${r[0]?.deleted ?? 0}_old_visits`;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Generic game engine used by /games/[slot] simulators.
|
||||||
|
// Records bets to inspection2.g5_point and updates g5_member.mb_point.
|
||||||
|
|
||||||
|
import { legacySql } from '@slot/db/legacy';
|
||||||
|
import type { SiteUser } from '@slot/themes';
|
||||||
|
|
||||||
|
export interface SpinResult {
|
||||||
|
win: boolean;
|
||||||
|
payout: number; // gross payout (point delta added back; loss = 0)
|
||||||
|
net: number; // net delta (+/-) versus the bet
|
||||||
|
symbols?: string[]; // visual reels
|
||||||
|
multiplier?: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameDef {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
reelCount: number;
|
||||||
|
symbols: string[]; // visible symbols (with weights via repetition)
|
||||||
|
paytable: Record<string, number>; // symbol -> multiplier when 3-of-a-kind
|
||||||
|
payAny2?: number; // small payout when 2 match
|
||||||
|
minBet: number;
|
||||||
|
maxBet: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GAMES: Record<string, GameDef> = {
|
||||||
|
fortunes: {
|
||||||
|
slug: 'fortunes', label: '88포춘',
|
||||||
|
reelCount: 3,
|
||||||
|
symbols: ['🐉','🐉','🐉','💰','💰','💰','🍊','🍊','🎯','7️⃣'],
|
||||||
|
paytable: { '🐉': 30, '💰': 18, '🍊': 7, '🎯': 4, '7️⃣': 80 },
|
||||||
|
payAny2: 1.5,
|
||||||
|
minBet: 100, maxBet: 100_000,
|
||||||
|
},
|
||||||
|
fivetreasures: {
|
||||||
|
slug: 'fivetreasures', label: '5트레져',
|
||||||
|
reelCount: 3,
|
||||||
|
symbols: ['💎','💎','🪙','🪙','🪙','🍀','🍀','🐅','🦁','👑'],
|
||||||
|
paytable: { '💎': 25, '🪙': 12, '🍀': 6, '🐅': 35, '🦁': 50, '👑': 100 },
|
||||||
|
payAny2: 1.4,
|
||||||
|
minBet: 100, maxBet: 100_000,
|
||||||
|
},
|
||||||
|
bacara: {
|
||||||
|
slug: 'bacara', label: '포인트 바카라',
|
||||||
|
reelCount: 1,
|
||||||
|
symbols: ['🟢','🟢','🟢','🟢','🔴','🔴','🔴','🔴','⚪'], // green=player, red=banker, white=tie
|
||||||
|
paytable: { '🟢': 2, '🔴': 1.95, '⚪': 8 },
|
||||||
|
minBet: 100, maxBet: 200_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickSymbol(g: GameDef): string {
|
||||||
|
return g.symbols[Math.floor(Math.random() * g.symbols.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spin(g: GameDef, bet: number): SpinResult {
|
||||||
|
const reels: string[] = [];
|
||||||
|
for (let i = 0; i < g.reelCount; i++) reels.push(pickSymbol(g));
|
||||||
|
if (g.reelCount === 1) {
|
||||||
|
const s = reels[0]!;
|
||||||
|
const m = g.paytable[s] ?? 0;
|
||||||
|
if (m > 0) {
|
||||||
|
const payout = Math.floor(bet * m);
|
||||||
|
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `${s} 적중! ×${m}` };
|
||||||
|
}
|
||||||
|
return { win: false, payout: 0, net: -bet, symbols: reels, message: '아쉽네요. 다음 기회에!' };
|
||||||
|
}
|
||||||
|
// 3-reel logic
|
||||||
|
const a = reels[0]!, b = reels[1]!, c = reels[2]!;
|
||||||
|
if (a === b && b === c) {
|
||||||
|
const m = g.paytable[a] ?? 5;
|
||||||
|
const payout = Math.floor(bet * m);
|
||||||
|
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `🎉 ${a}${a}${a} 잭팟! ×${m}` };
|
||||||
|
}
|
||||||
|
if (g.payAny2 && (a === b || b === c || a === c)) {
|
||||||
|
const m = g.payAny2;
|
||||||
|
const payout = Math.floor(bet * m);
|
||||||
|
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `2-매치 ×${m}` };
|
||||||
|
}
|
||||||
|
return { win: false, payout: 0, net: -bet, symbols: reels, message: '꽝!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function placeBetAndSpin(slug: string, user: SiteUser, bet: number): Promise<{ ok: boolean; error?: string; result?: SpinResult; newPoint?: 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}` };
|
||||||
|
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' };
|
||||||
|
|
||||||
|
const result = spin(g, b);
|
||||||
|
const newPoint = balance + result.net;
|
||||||
|
const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
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'})
|
||||||
|
`;
|
||||||
|
}).catch((e) => { console.error('placeBetAndSpin fail', e); throw e; });
|
||||||
|
return { ok: true, result, newPoint };
|
||||||
|
}
|
||||||
@@ -133,6 +133,16 @@ async function iteration(i) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await check('GET /shop (item list)', async () => {
|
||||||
|
const r = await req('GET', '/shop');
|
||||||
|
return r.status === 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('GET /admin (level guard, redirect to / since testlogin lv=2)', async () => {
|
||||||
|
const r = await req('GET', '/admin');
|
||||||
|
return r.status === 200 || r.status === 302 || r.status === 307;
|
||||||
|
});
|
||||||
|
|
||||||
await check('POST /api/auth/logout', async () => {
|
await check('POST /api/auth/logout', async () => {
|
||||||
const r = await req('POST', '/api/auth/logout');
|
const r = await req('POST', '/api/auth/logout');
|
||||||
return r.status === 303 || r.status === 302;
|
return r.status === 303 || r.status === 302;
|
||||||
|
|||||||
Reference in New Issue
Block a user