From d175f46a09466d039225ede8632f96b42d6774db Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 28 Apr 2026 07:19:28 +0900 Subject: [PATCH] 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) --- .../apps/web/src/app/admin/boards/actions.ts | 44 ++++++ .../apps/web/src/app/admin/boards/page.tsx | 84 +++++++--- .../web/src/app/games/[game]/play/page.tsx | 125 +++++++++++++++ .../apps/web/src/app/shop/[itemId]/page.tsx | 105 +++++++++++++ next-app/apps/web/src/app/shop/cart/page.tsx | 143 ++++++++++++++++++ .../web/src/app/shop/order/[odId]/page.tsx | 66 ++++++++ next-app/apps/web/src/lib/cron-tick.ts | 68 +++++++++ next-app/apps/web/src/lib/game-engine.ts | 104 +++++++++++++ next-app/scripts/verify-react-stack.mjs | 10 ++ 9 files changed, 725 insertions(+), 24 deletions(-) create mode 100644 next-app/apps/web/src/app/admin/boards/actions.ts create mode 100644 next-app/apps/web/src/app/games/[game]/play/page.tsx create mode 100644 next-app/apps/web/src/app/shop/[itemId]/page.tsx create mode 100644 next-app/apps/web/src/app/shop/cart/page.tsx create mode 100644 next-app/apps/web/src/app/shop/order/[odId]/page.tsx create mode 100644 next-app/apps/web/src/lib/cron-tick.ts create mode 100644 next-app/apps/web/src/lib/game-engine.ts diff --git a/next-app/apps/web/src/app/admin/boards/actions.ts b/next-app/apps/web/src/app/admin/boards/actions.ts new file mode 100644 index 0000000..8354d85 --- /dev/null +++ b/next-app/apps/web/src/app/admin/boards/actions.ts @@ -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 }; +} diff --git a/next-app/apps/web/src/app/admin/boards/page.tsx b/next-app/apps/web/src/app/admin/boards/page.tsx index 8f61270..e0e8377 100644 --- a/next-app/apps/web/src/app/admin/boards/page.tsx +++ b/next-app/apps/web/src/app/admin/boards/page.tsx @@ -1,36 +1,72 @@ import { legacySql } from '@slot/db/legacy'; +import { updateBoardSubject } from './actions'; 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() { - 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` 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 `.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 ( -
-

게시판 관리 ({rows.length})

- - - - - - {rows.map((r) => ( - - - - - - - - +
+
+
게시판관리
+

게시판 관리 ({rows.length})

+

제목 즉시 변경 가능. 글/댓글 카운트는 g5_board의 캐시값.

+
+ +
+
그룹슬러그제목댓글비밀글관리
{r.gr_id}{r.bo_table}{r.bo_subject}{r.bo_count_write?.toLocaleString() ?? 0}{r.bo_count_comment?.toLocaleString() ?? 0}{r.bo_use_secret ? '✓' : '-'}설정
+ + + + + + + + + - ))} - -
그룹슬러그제목 (변경 가능)댓글비밀바로가기
-
+ + + {rows.map((r) => ( + + {r.gr_id} + {r.bo_table} + +
+ + + +
+ + {r.bo_count_write?.toLocaleString() ?? 0} + {r.bo_count_comment?.toLocaleString() ?? 0} + {r.bo_use_secret ? '✓' : '–'} + /{r.bo_table} + + ))} + + + + ); } -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 }); diff --git a/next-app/apps/web/src/app/games/[game]/play/page.tsx b/next-app/apps/web/src/app/games/[game]/play/page.tsx new file mode 100644 index 0000000..6e16d3a --- /dev/null +++ b/next-app/apps/web/src/app/games/[game]/play/page.tsx @@ -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 { + return await legacySql` + 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 ( +
+
+
+
+
PLAY
+

{def.label}

+

최소 {def.minBet.toLocaleString()}p · 최대 {def.maxBet.toLocaleString()}p

+
+
+
내 포인트
+
{balance.toLocaleString()}p
+
+
+
+ +
+
+ {reels.map((s: string, i: number) => ( +
{s}
+ ))} +
+ {lastResult && ( +
+ {lastResult.ok ? lastResult.message : `에러: ${lastResult.error}`} + {lastResult.ok && ( +
+ 순손익 {lastResult.net >= 0 ? '+' : ''}{lastResult.net.toLocaleString()}p · 잔액 {Number(lastResult.newPoint ?? balance).toLocaleString()}p +
+ )} +
+ )} +
+ +
+ +
+ {[100, 500, 1000, 5000, 10000, 50000].filter((v) => v >= def.minBet && v <= def.maxBet && v <= balance).map((v) => ( + + ))} +
+ +
+ +
+

최근 10판 (g5_point 기록)

+
    + {recent.length === 0 &&
  • 기록 없음
  • } + {recent.map((r, i) => ( +
  • + {r.po_content} + = 0 ? 'text-emerald-700' : 'text-rose-600'}`}> + {Number(r.po_point) >= 0 ? '+' : ''}{Number(r.po_point).toLocaleString()}p + +
  • + ))} +
+
+ ← 게임 정보 +
+ ); +} diff --git a/next-app/apps/web/src/app/shop/[itemId]/page.tsx b/next-app/apps/web/src/app/shop/[itemId]/page.tsx new file mode 100644 index 0000000..788a8e4 --- /dev/null +++ b/next-app/apps/web/src/app/shop/[itemId]/page.tsx @@ -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 { + try { + const rows = await legacySql` + 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 ( +
+
+
🎁
+
+
+ {item.it_brand && {item.it_brand}} +

{item.it_name}

+ {item.it_basic &&

{item.it_basic}

} +
+
+ {price.toLocaleString()}p + {pointReward > 0 && +{pointReward.toLocaleString()}p 적립} +
+
+
재고
+
{stock > 0 ? stock.toLocaleString() : 품절}
+ {item.it_maker && (<>
제조사
{item.it_maker}
)} +
상태
+
{Number(item.it_use) > 0 ? '판매중' : '판매중지'}
+
+ {user && stock > 0 && Number(item.it_use) > 0 ? ( +
+ + +
+ ) : !user ? ( + + 로그인 후 구매 + + ) : ( +
현재 판매 불가
+ )} +
+ {item.it_explan &&
} + ← 상품 목록으로 +
+
+ ); +} diff --git a/next-app/apps/web/src/app/shop/cart/page.tsx b/next-app/apps/web/src/app/shop/cart/page.tsx new file mode 100644 index 0000000..bddaa19 --- /dev/null +++ b/next-app/apps/web/src/app/shop/cart/page.tsx @@ -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` + 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` + 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 ( +
+
+
CART
+

🛒 장바구니

+

결제 시 보유 포인트로 차감됩니다 (현 보유: {point.toLocaleString()}p)

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

장바구니가 비어 있습니다.

+ 상품 둘러보기 +
+ ) : ( + <> +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + + + + + +
상품단가수량합계
{r.it_name}{Number(r.ct_price).toLocaleString()}p{r.ct_qty}{(Number(r.ct_price) * Number(r.ct_qty)).toLocaleString()}p +
+ + +
+
총 결제 포인트{total.toLocaleString()}p
+
+
+ +
+ + )} +
+ ); +} diff --git a/next-app/apps/web/src/app/shop/order/[odId]/page.tsx b/next-app/apps/web/src/app/shop/order/[odId]/page.tsx new file mode 100644 index 0000000..8ca8ad9 --- /dev/null +++ b/next-app/apps/web/src/app/shop/order/[odId]/page.tsx @@ -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` + 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` + 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 ( +
+
+
ORDER #{o.od_id}
+

✅ 결제 완료

+

{new Date(o.od_time).toLocaleString('ko-KR')} · {o.od_settle_case}

+
+
+

주문 상품 ({o.od_cart_count}건)

+
    + {items.map((it) => ( +
  • + {it.it_name} + ×{it.ct_qty} + {(Number(it.ct_price) * Number(it.ct_qty)).toLocaleString()}p +
  • + ))} +
+
+ 총 결제 포인트 + {Number(o.od_cart_price).toLocaleString()}p +
+
+
+ 계속 쇼핑 + 마이페이지로 +
+
+ ); +} diff --git a/next-app/apps/web/src/lib/cron-tick.ts b/next-app/apps/web/src/lib/cron-tick.ts new file mode 100644 index 0000000..8a2d48a --- /dev/null +++ b/next-app/apps/web/src/lib/cron-tick.ts @@ -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): 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 { + 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`; + }), + ]); +} diff --git a/next-app/apps/web/src/lib/game-engine.ts b/next-app/apps/web/src/lib/game-engine.ts new file mode 100644 index 0000000..0b857db --- /dev/null +++ b/next-app/apps/web/src/lib/game-engine.ts @@ -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; // symbol -> multiplier when 3-of-a-kind + payAny2?: number; // small payout when 2 match + minBet: number; + maxBet: number; +} + +export const GAMES: Record = { + 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 }; +} diff --git a/next-app/scripts/verify-react-stack.mjs b/next-app/scripts/verify-react-stack.mjs index 6eaf231..d4938ab 100644 --- a/next-app/scripts/verify-react-stack.mjs +++ b/next-app/scripts/verify-react-stack.mjs @@ -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 () => { const r = await req('POST', '/api/auth/logout'); return r.status === 303 || r.status === 302;