From 0d248eb6aeb22ae0762ec01b78cc56dc01b67776 Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 28 Apr 2026 14:53:58 +0900 Subject: [PATCH] React standalone on :8088, mypage tabs, sms config, wild symbols, cross-verify x16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone deployment: - React Next.js no longer uses basePath; served directly at port 8088 - nginx slot-react vhost on listen 8088 proxies / -> 127.0.0.1:3000 - 80 default vhost simplified to PHP-only (no /react path) - ufw allow 8088/tcp User pages: - /mypage/follower (g5_eyoom_follow target_id=mb_id) - /mypage/following (g5_eyoom_follow mb_id=mb_id) - /mypage/activity (g5_eyoom_activity + g5_point ledger) - /mypage/password (verifyLegacyPassword + hashPassword) Admin: - /admin/sms/config (sms5_config single-row CRUD) Game engine: - Wild symbol with 7% spawn rate, substitutes for any in 3-of-a-kind - Wild count multiplier: payout = base × (1 + wildCount × 0.5) Verify-cross: now tests PHP: home, board, login, /adm/ React: home, board, login, mypage, shop, games/play Cross: robots.txt block on both Theme cookie: 4 themes (basic/eyoom/amina/youngcart) round-trip 50 iterations × 16 = 800/800 PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- next-app/apps/web/next.config.mjs | 2 - .../web/src/app/admin/sms/config/page.tsx | 65 +++++++++++++++++ .../apps/web/src/app/mypage/activity/page.tsx | 65 +++++++++++++++++ .../apps/web/src/app/mypage/follower/page.tsx | 70 +++++++++++++++++++ .../web/src/app/mypage/following/page.tsx | 35 ++++++++++ .../apps/web/src/app/mypage/password/page.tsx | 63 +++++++++++++++++ next-app/apps/web/src/lib/game-engine.ts | 29 ++++++-- next-app/scripts/verify-cross.mjs | 38 ++++++---- 8 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 next-app/apps/web/src/app/admin/sms/config/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/activity/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/follower/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/following/page.tsx create mode 100644 next-app/apps/web/src/app/mypage/password/page.tsx diff --git a/next-app/apps/web/next.config.mjs b/next-app/apps/web/next.config.mjs index 018083f..3c5929f 100644 --- a/next-app/apps/web/next.config.mjs +++ b/next-app/apps/web/next.config.mjs @@ -1,8 +1,6 @@ /** @type {import('next').NextConfig} */ -const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; const nextConfig = { reactStrictMode: true, - ...(basePath ? { basePath, assetPrefix: basePath } : {}), transpilePackages: ['@slot/themes', '@slot/db', '@slot/auth'], serverExternalPackages: ['postgres', '@node-rs/argon2'], typescript: { ignoreBuildErrors: true }, diff --git a/next-app/apps/web/src/app/admin/sms/config/page.tsx b/next-app/apps/web/src/app/admin/sms/config/page.tsx new file mode 100644 index 0000000..f0b4d23 --- /dev/null +++ b/next-app/apps/web/src/app/admin/sms/config/page.tsx @@ -0,0 +1,65 @@ +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 ConfigRow { id: number; sms_id: string; sms_hp: string; sms_callback: string } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function saveConfig(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = Number(formData.get('id') ?? 0); + const sms_id = String(formData.get('sms_id') ?? '').slice(0, 100); + const sms_hp = String(formData.get('sms_hp') ?? '').slice(0, 20); + const callback = String(formData.get('callback') ?? '').slice(0, 20); + if (id) { + await legacySql` + UPDATE inspection2.sms5_config SET sms_id = ${sms_id}, sms_hp = ${sms_hp}, sms_callback = ${callback} WHERE id = ${id} + `.catch(() => {}); + } else if (sms_id) { + await legacySql` + INSERT INTO inspection2.sms5_config (sms_id, sms_hp, sms_callback) VALUES (${sms_id}, ${sms_hp}, ${callback}) + `.catch(() => {}); + } + revalidatePath('/admin/sms/config'); +} + +export default async function SmsConfigAdmin() { + await requireAdmin(); + const rows = await legacySql`SELECT id, sms_id, sms_hp, sms_callback FROM inspection2.sms5_config ORDER BY id LIMIT 5`.catch(() => []); + const c = rows[0] ?? { id: 0, sms_id: '', sms_hp: '', sms_callback: '' }; + + return ( +
+
+
SMS 관리
+

SMS 기본설정

+

발신 ID/번호/콜백 (sms5_config). 실제 SMS API 연동은 M9에서.

+
+
+ + + + + + +
+ ); +} + +function Field({ name, label, defaultValue }: { name: string; label: string; defaultValue: string }) { + return ( +
+ + +
+ ); +} diff --git a/next-app/apps/web/src/app/mypage/activity/page.tsx b/next-app/apps/web/src/app/mypage/activity/page.tsx new file mode 100644 index 0000000..bc0a933 --- /dev/null +++ b/next-app/apps/web/src/app/mypage/activity/page.tsx @@ -0,0 +1,65 @@ +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 ActivityRow { id: number; activity: string; bo_table: string | null; wr_id: number | null; reg_date: Date } +interface PointRow { po_id: number; po_content: string; po_point: number; po_datetime: Date; po_mb_point: number } + +export default async function ActivityPage() { + const user = await getCurrentSiteUser(); + if (!user) redirect('/login?next=/mypage/activity'); + const acts = await legacySql` + SELECT id, activity, bo_table, wr_id, reg_date FROM inspection2.g5_eyoom_activity + WHERE mb_id = ${user.loginId} ORDER BY id DESC LIMIT 30 + `.catch(() => []); + const points = await legacySql` + SELECT po_id, po_content, po_point, po_datetime, po_mb_point FROM inspection2.g5_point + WHERE mb_id = ${user.loginId} ORDER BY po_id DESC LIMIT 30 + `.catch(() => []); + return ( +
+
+
ACTIVITY
+

⚡ 활동 내역

+

최근 활동 + 포인트 원장

+
+
+

최근 활동 ({acts.length})

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

활동 기록 없음

+ ) : ( +
    + {acts.map((a) => ( +
  • + {a.activity} {a.bo_table && /{a.bo_table}/{a.wr_id}} + {a.reg_date && new Date(a.reg_date).toLocaleDateString('ko-KR')} +
  • + ))} +
+ )} +
+
+

포인트 원장 (최근 30건)

+
    + {points.length === 0 ? ( +
  • 기록 없음
  • + ) : points.map((p) => ( +
  • + {p.po_content} + + = 0 ? 'text-emerald-700' : 'text-rose-600'}`}> + {Number(p.po_point) >= 0 ? '+' : ''}{Number(p.po_point).toLocaleString()}p + + 잔 {Number(p.po_mb_point).toLocaleString()} + +
  • + ))} +
+
+ ← 마이페이지 +
+ ); +} diff --git a/next-app/apps/web/src/app/mypage/follower/page.tsx b/next-app/apps/web/src/app/mypage/follower/page.tsx new file mode 100644 index 0000000..42cb337 --- /dev/null +++ b/next-app/apps/web/src/app/mypage/follower/page.tsx @@ -0,0 +1,70 @@ +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 FollowRow { mb_id: string; mb_nick: string; mb_level: number; mb_point: number; reg_date: Date | null } + +async function getRowsBidirectional(loginId: string, dir: 'follower' | 'following'): Promise { + const t = `inspection2.g5_eyoom_follow`; + const sql = dir === 'follower' + ? legacySql` + SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date + FROM ${legacySql.unsafe(t)} f + INNER JOIN inspection2.g5_member m ON m.mb_id = f.mb_id + WHERE f.target_id = ${loginId} + ORDER BY f.reg_date DESC LIMIT 100 + ` + : legacySql` + SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date + FROM ${legacySql.unsafe(t)} f + INNER JOIN inspection2.g5_member m ON m.mb_id = f.target_id + WHERE f.mb_id = ${loginId} + ORDER BY f.reg_date DESC LIMIT 100 + `; + return await sql.catch(() => []); +} + +export default async function FollowerPage() { + const user = await getCurrentSiteUser(); + if (!user) redirect('/login?next=/mypage/follower'); + const followers = await getRowsBidirectional(user.loginId, 'follower'); + return ( +
+
+
FOLLOWERS
+

💖 나를 팔로우하는 회원

+

{followers.length}명

+
+ +
+ 팔로잉 보기 → + ← 마이페이지 +
+
+ ); +} + +export function FollowGrid({ rows, empty }: { rows: FollowRow[]; empty: string }) { + if (rows.length === 0) { + return

{empty}

; + } + return ( +
    + {rows.map((r) => ( +
  • + + {r.mb_nick?.[0] ?? '?'} +
    + {r.mb_nick} +
    Lv.{r.mb_level} · {(r.mb_point ?? 0).toLocaleString()}p
    +
    + {r.reg_date && {new Date(r.reg_date).toLocaleDateString('ko-KR')}} + +
  • + ))} +
+ ); +} diff --git a/next-app/apps/web/src/app/mypage/following/page.tsx b/next-app/apps/web/src/app/mypage/following/page.tsx new file mode 100644 index 0000000..c9b682c --- /dev/null +++ b/next-app/apps/web/src/app/mypage/following/page.tsx @@ -0,0 +1,35 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { FollowGrid } from '../follower/page'; + +export const dynamic = 'force-dynamic'; + +interface FollowRow { mb_id: string; mb_nick: string; mb_level: number; mb_point: number; reg_date: Date | null } + +export default async function FollowingPage() { + const user = await getCurrentSiteUser(); + if (!user) redirect('/login?next=/mypage/following'); + const rows = await legacySql` + SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date + FROM inspection2.g5_eyoom_follow f + INNER JOIN inspection2.g5_member m ON m.mb_id = f.target_id + WHERE f.mb_id = ${user.loginId} + ORDER BY f.reg_date DESC LIMIT 100 + `.catch(() => []); + return ( +
+
+
FOLLOWING
+

⭐ 내가 팔로우 중인 회원

+

{rows.length}명

+
+ +
+ ← 팔로워 + 마이페이지 +
+
+ ); +} diff --git a/next-app/apps/web/src/app/mypage/password/page.tsx b/next-app/apps/web/src/app/mypage/password/page.tsx new file mode 100644 index 0000000..481ace2 --- /dev/null +++ b/next-app/apps/web/src/app/mypage/password/page.tsx @@ -0,0 +1,63 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; +import { hashPassword, verifyLegacyPassword } from '@slot/auth'; + +export const dynamic = 'force-dynamic'; + +export default async function PasswordChangePage({ searchParams }: { searchParams: Promise<{ status?: string; error?: string }> }) { + const sp = await searchParams; + const user = await getCurrentSiteUser(); + if (!user) redirect('/login?next=/mypage/password'); + + async function changePassword(formData: FormData) { + 'use server'; + const u = await getCurrentSiteUser(); + if (!u) return; + const cur = String(formData.get('current') ?? ''); + const next = String(formData.get('next') ?? ''); + const conf = String(formData.get('confirm') ?? ''); + if (next.length < 4 || next !== conf) { + redirect('/mypage/password?error=mismatch'); + } + const rows = await legacySql<{ mb_password: string }[]>`SELECT mb_password FROM inspection2.g5_member WHERE mb_id = ${u.loginId}`.catch(() => []); + if (!rows[0]) redirect('/mypage/password?error=not_found'); + const stored = rows[0]!.mb_password; + let ok = false; + try { ok = await verifyLegacyPassword(cur, stored); } catch { ok = false; } + if (!ok) redirect('/mypage/password?error=current_wrong'); + let newHash = ''; + try { newHash = await hashPassword(next); } catch { newHash = ''; } + if (!newHash || newHash.length > 250) redirect('/mypage/password?error=hash_fail'); + await legacySql`UPDATE inspection2.g5_member SET mb_password = ${newHash} WHERE mb_id = ${u.loginId}`.catch(() => {}); + revalidatePath('/mypage/password'); + redirect('/mypage/password?status=ok'); + } + + return ( +
+
+
SECURITY
+

🔑 비밀번호 변경

+
+ {sp.status === 'ok' &&
✅ 비밀번호가 변경되었습니다.
} + {sp.error &&
에러: {sp.error}
} +
+ + + + + +
+ ); +} + +function Field({ name, label, type = 'text' }: { name: string; label: string; type?: string }) { + return ( +
+ + +
+ ); +} diff --git a/next-app/apps/web/src/lib/game-engine.ts b/next-app/apps/web/src/lib/game-engine.ts index 3749262..34b4ad1 100644 --- a/next-app/apps/web/src/lib/game-engine.ts +++ b/next-app/apps/web/src/lib/game-engine.ts @@ -20,6 +20,8 @@ export interface GameDef { symbols: string[]; // visible symbols (with weights via repetition) paytable: Record; // symbol -> multiplier when 3-of-a-kind payAny2?: number; // small payout when 2 match + wild?: string; // wild symbol substitutes for any + scatter?: string; // scatter triggers free spins minBet: number; maxBet: number; } @@ -153,8 +155,12 @@ function pickSymbol(g: GameDef): string { } export function spin(g: GameDef, bet: number): SpinResult { + // 5% chance of inserting wild ⭐ — boosts excitement on slots that defined it. const reels: string[] = []; - for (let i = 0; i < g.reelCount; i++) reels.push(pickSymbol(g)); + for (let i = 0; i < g.reelCount; i++) { + if (g.wild && Math.random() < 0.07) reels.push(g.wild); + else reels.push(pickSymbol(g)); + } if (g.reelCount === 1) { const s = reels[0]!; const m = g.paytable[s] ?? 0; @@ -164,12 +170,25 @@ export function spin(g: GameDef, bet: number): SpinResult { } 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 wildCount = g.wild ? reels.filter((s) => s === g.wild).length : 0; + // Treat wild as match-anything: any 3-of-a-kind allowing wild substitutes + const matchSymbol = (() => { + if (!g.wild) return a === b && b === c ? a : null; + const nonWild = reels.filter((s) => s !== g.wild); + if (nonWild.length === 0) return g.wild; // all wilds! + const ref = nonWild[0]!; + return nonWild.every((s) => s === ref) ? ref : null; + })(); + if (matchSymbol) { + const baseM = g.paytable[matchSymbol] ?? 5; + const wildMult = wildCount > 0 ? 1 + wildCount * 0.5 : 1; + const m = baseM * wildMult; const payout = Math.floor(bet * m); - return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `🎉 ${a}${a}${a} 잭팟! ×${m}` }; + return { + win: true, payout, net: payout - bet, symbols: reels, multiplier: m, + message: wildCount > 0 ? `🌟 와일드 ${wildCount}개 + ${matchSymbol}${matchSymbol}${matchSymbol}! ×${m.toFixed(2)}` : `🎉 ${matchSymbol}${matchSymbol}${matchSymbol} 잭팟! ×${m}`, + }; } if (g.payAny2 && (a === b || b === c || a === c)) { const m = g.payAny2; diff --git a/next-app/scripts/verify-cross.mjs b/next-app/scripts/verify-cross.mjs index 8b6f586..7d6b36d 100644 --- a/next-app/scripts/verify-cross.mjs +++ b/next-app/scripts/verify-cross.mjs @@ -6,8 +6,8 @@ // Usage: // ITERATIONS=5 node scripts/verify-cross.mjs const HOST = process.env.HOST || 'http://103.31.14.201'; -const PHP = HOST; -const REACT = HOST + '/react'; +const PHP = process.env.PHP_BASE || HOST; +const REACT = process.env.REACT_BASE || (HOST + ':8088'); const ITER = Number(process.env.ITERATIONS || 5); const PHP_USER = process.env.PHP_USER || 'admin'; const PHP_PASS = process.env.PHP_PASS || 'clone1234'; @@ -80,15 +80,15 @@ async function iteration(i) { }); // --- React side --- - await check('[REACT] GET /react/ (homepage 200)', async () => { + await check('[REACT] GET / (homepage 200)', async () => { const r = await fetchOk(REACT + '/'); return r.status === 200 || r.status === 308; }); - await check('[REACT] GET /react/robots.txt OR root (any 200/308)', async () => { + await check('[REACT] GET /free (board 200)', async () => { const r = await fetchOk(REACT + '/free'); return r.status === 200 || r.status === 308; }); - await check('[REACT] POST /react/api/auth/login as testlogin', async () => { + await check('[REACT] POST /api/auth/login as testlogin', async () => { const r = await fetchOk(REACT + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': reactCookie }, @@ -97,26 +97,38 @@ async function iteration(i) { reactCookie = takeCookie(reactCookie, r); return r.status === 303 || r.status === 302; }); - await check('[REACT] GET /react/mypage as testlogin', async () => { + await check('[REACT] GET /mypage as testlogin', async () => { const r = await fetchOk(REACT + '/mypage', { headers: { Cookie: reactCookie } }); return r.status === 200 || r.status === 308; }); - await check('[REACT] GET /react/shop (200)', async () => { + await check('[REACT] GET /shop (200)', async () => { const r = await fetchOk(REACT + '/shop'); return r.status === 200 || r.status === 308; }); + await check('[REACT] GET /games/fortunes/play (auth-required, 307)', async () => { + const r = await fetchOk(REACT + '/games/fortunes/play'); + return r.status === 307 || r.status === 200 || r.status === 308; + }); + for (const t of ['basic', 'eyoom', 'amina', 'youngcart']) { + await check(`[THEME] /api/ui/theme?t=${t} sets cookie`, async () => { + const r = await fetchOk(REACT + `/api/ui/theme?t=${t}`); + const cs = r.headers.get('set-cookie') || ''; + return cs.includes(`slot_theme=${t}`); + }); + } // --- Cross-stack invariants (data parity) --- // PHP and React see the same MariaDB-vs-PG-restored data; counts on // public-facing pages should match within +/- 1 (timing of restore). - await check('[CROSS] PHP /robots.txt blocked AND React /robots.txt blocked', async () => { - const r = await fetchOk(HOST + '/robots.txt'); - if (r.status !== 200) return false; - const t = await r.text(); - return /Disallow: \//.test(t); + await check('[CROSS] PHP /robots.txt + React /robots.txt both block all bots', async () => { + const a = await fetchOk(PHP + '/robots.txt'); + const b = await fetchOk(REACT + '/robots.txt'); + if (a.status !== 200 || b.status !== 200) return false; + const at = await a.text(); const bt = await b.text(); + return /Disallow: \//.test(at) && /Disallow: \//.test(bt); }); - await check('[REACT] POST /react/api/auth/logout', async () => { + await check('[REACT] POST /api/auth/logout', async () => { const r = await fetchOk(REACT + '/api/auth/logout', { method: 'POST', headers: { Cookie: reactCookie } }); return r.status === 303 || r.status === 302; });