Phase D: 닉네임 기반 DB 세이브 동기화
- villages 테이블(pg-jskim): nickname PK + 보호 토큰 + JSONB 세이브 - API: /api/village/check(중복)·register(선점)·save(토큰 검증 업서트)·load - 닉네임 모달: 촌장 냥이 인사 직후 게임 차단, 입력 멈춘 1초 후 자동 중복 체크(디바운스), 사용 가능할 때만 결정 버튼 활성화 한글·영문·숫자 2~12자 검증, 서버 불가 시 로컬 시작 후 재시도 - 동기화: 30초마다 자동 push + 종료 시 push, 부팅 시 서버가 더 최신이면 교체(replaceState → 마이그레이션·오프라인 정산 재수행) - 기존 세이브도 닉네임 미설정이면 모달 표시 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isAvailable, validNickname } from "@/lib/villages";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const name = new URL(req.url).searchParams.get("name");
|
||||||
|
if (!validNickname(name))
|
||||||
|
return NextResponse.json(
|
||||||
|
{ available: false, reason: "한글·영문·숫자 2~12자만 가능해요" },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return NextResponse.json({ available: await isAvailable(name) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { loadVillage, validNickname } from "@/lib/villages";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const nickname = url.searchParams.get("nickname");
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
if (!validNickname(nickname) || !token)
|
||||||
|
return NextResponse.json({ data: null }, { status: 400 });
|
||||||
|
try {
|
||||||
|
return NextResponse.json({ data: await loadVillage(nickname, token) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { register, validNickname } from "@/lib/villages";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { nickname, token } = await req.json().catch(() => ({}));
|
||||||
|
if (!validNickname(nickname) || typeof token !== "string" || token.length < 8)
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
try {
|
||||||
|
return NextResponse.json({ ok: await register(nickname, token) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { saveVillage, validNickname } from "@/lib/villages";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { nickname, token, data } = await req.json().catch(() => ({}));
|
||||||
|
if (!validNickname(nickname) || typeof token !== "string" || !data)
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const ok = await saveVillage(nickname, token, data);
|
||||||
|
return NextResponse.json({ ok }, { status: ok ? 200 : 403 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "db" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useGame } from "@/hooks/useGame";
|
import { useGame } from "@/hooks/useGame";
|
||||||
import VillageView, { type PlacingState } from "@/components/game/VillageView";
|
import VillageView, { type PlacingState } from "@/components/game/VillageView";
|
||||||
import DbStatus from "@/components/DbStatus";
|
import DbStatus from "@/components/DbStatus";
|
||||||
|
import NicknameModal from "@/components/game/NicknameModal";
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
effectiveRate,
|
effectiveRate,
|
||||||
@@ -231,6 +232,11 @@ export default function Game() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
|
||||||
|
{!state.nickname && (state.tutorialStep ?? TUTORIAL_DONE) >= 1 && (
|
||||||
|
<NicknameModal onDone={() => dispatch(() => undefined)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 배치 모드 힌트 ── */}
|
{/* ── 배치 모드 힌트 ── */}
|
||||||
{placing && (
|
{placing && (
|
||||||
<div className="wood-plank font-jua absolute bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 px-5 py-2.5 text-sm">
|
<div className="wood-plank font-jua absolute bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 px-5 py-2.5 text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { catSprite } from "@/lib/game/data";
|
||||||
|
import { checkNickname, newSyncToken, registerNickname, pushSave } from "@/lib/game/sync";
|
||||||
|
import { engine } from "@/lib/game/engine";
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| "idle"
|
||||||
|
| "checking"
|
||||||
|
| "available"
|
||||||
|
| "taken"
|
||||||
|
| "invalid"
|
||||||
|
| "offline";
|
||||||
|
|
||||||
|
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
||||||
|
|
||||||
|
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const latest = useRef("");
|
||||||
|
|
||||||
|
// 입력이 멈춘 1초 후 자동 중복 체크
|
||||||
|
useEffect(() => {
|
||||||
|
latest.current = name;
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
if (name.length === 0) {
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!NICKNAME_RE.test(name)) {
|
||||||
|
setStatus("invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("checking");
|
||||||
|
timer.current = setTimeout(async () => {
|
||||||
|
const result = await checkNickname(name);
|
||||||
|
if (latest.current !== name) return; // 입력이 바뀌었으면 무시
|
||||||
|
if (result === null) setStatus("offline");
|
||||||
|
else setStatus(result.available ? "available" : "taken");
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
if (status !== "available" || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
const token = newSyncToken();
|
||||||
|
const ok = await registerNickname(name, token);
|
||||||
|
if (ok === false) {
|
||||||
|
// 그 사이 누가 선점
|
||||||
|
setStatus("taken");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ok === null(서버 불가)이어도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
||||||
|
engine.setNickname(name, token);
|
||||||
|
void pushSave();
|
||||||
|
setSubmitting(false);
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLine: Record<Status, { text: string; cls: string }> = {
|
||||||
|
idle: { text: "마을 문패에 걸 이름이야. 한글·영문·숫자 2~12자!", cls: "text-[#8a7551]" },
|
||||||
|
invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" },
|
||||||
|
checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" },
|
||||||
|
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
||||||
|
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
||||||
|
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="panel-wood w-full max-w-md p-2.5">
|
||||||
|
<div className="panel-paper flex flex-col items-center p-6 text-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={catSprite("korean_shorthair", "idle")}
|
||||||
|
alt="촌장 냥이"
|
||||||
|
className="h-28 w-28 object-contain drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<h2 className="font-jua mt-2 text-xl text-[#4a3418]">
|
||||||
|
그런데… 네 이름이 뭐야?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[#6f5c39]">
|
||||||
|
촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value.trim())}
|
||||||
|
maxLength={12}
|
||||||
|
autoFocus
|
||||||
|
placeholder="닉네임 입력"
|
||||||
|
className="font-jua mt-4 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-3 text-center text-lg text-[#3f2c14] outline-none focus:border-[#7fb539]"
|
||||||
|
/>
|
||||||
|
<p className={`mt-2 h-5 text-xs ${statusLine[status].cls}`}>
|
||||||
|
{statusLine[status].text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={status !== "available" || submitting}
|
||||||
|
className="nature-btn mt-3 w-full py-2.5 text-base disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{submitting ? "문패 만드는 중…" : "이 이름으로 할래! 🐾"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { engine } from "@/lib/game/engine";
|
import { engine } from "@/lib/game/engine";
|
||||||
|
import { pullSave, pushSave } from "@/lib/game/sync";
|
||||||
import type { GameState } from "@/lib/game/types";
|
import type { GameState } from "@/lib/game/types";
|
||||||
|
|
||||||
const SYNC_INTERVAL_MS = 300;
|
const SYNC_INTERVAL_MS = 300;
|
||||||
@@ -18,6 +19,10 @@ export function useGame() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
engine.init();
|
engine.init();
|
||||||
setState(engine.snapshot());
|
setState(engine.snapshot());
|
||||||
|
// 서버 세이브가 더 최신이면 교체 (닉네임 있는 경우)
|
||||||
|
void pullSave().then((replaced) => {
|
||||||
|
if (replaced) setState(engine.snapshot());
|
||||||
|
});
|
||||||
|
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
@@ -36,10 +41,13 @@ export function useGame() {
|
|||||||
raf = requestAnimationFrame(loop);
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
const saveTimer = setInterval(() => engine.save(), SAVE_INTERVAL_MS);
|
const saveTimer = setInterval(() => engine.save(), SAVE_INTERVAL_MS);
|
||||||
|
const syncTimer = setInterval(() => void pushSave(), 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
clearInterval(saveTimer);
|
clearInterval(saveTimer);
|
||||||
|
clearInterval(syncTimer);
|
||||||
engine.save();
|
engine.save();
|
||||||
|
void pushSave();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,12 @@ export const engine = {
|
|||||||
} catch {
|
} catch {
|
||||||
// 저장 데이터가 깨졌으면 새로 시작
|
// 저장 데이터가 깨졌으면 새로 시작
|
||||||
}
|
}
|
||||||
|
if (!state) state = createInitialState();
|
||||||
|
return this.finishLoad();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 마이그레이션 + 오프라인 정산 (로컬/원격 로드 공통) */
|
||||||
|
finishLoad(): GameState {
|
||||||
if (!state) state = createInitialState();
|
if (!state) state = createInitialState();
|
||||||
// 구버전 세이브 마이그레이션 (필드 보강)
|
// 구버전 세이브 마이그레이션 (필드 보강)
|
||||||
state.resources.stone ??= 0;
|
state.resources.stone ??= 0;
|
||||||
@@ -340,6 +346,21 @@ export const engine = {
|
|||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 서버 세이브로 교체 (더 최신일 때) — 마이그레이션·오프라인 정산 재수행 */
|
||||||
|
replaceState(data: GameState) {
|
||||||
|
state = data;
|
||||||
|
this.finishLoad();
|
||||||
|
this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 닉네임·토큰 설정 */
|
||||||
|
setNickname(nickname: string, token: string) {
|
||||||
|
if (!state) return;
|
||||||
|
state.nickname = nickname;
|
||||||
|
state.syncToken = token;
|
||||||
|
this.save();
|
||||||
|
},
|
||||||
|
|
||||||
/** 오프라인 정산 리포트 (1회성) */
|
/** 오프라인 정산 리포트 (1회성) */
|
||||||
consumeOfflineReport(): number | null {
|
consumeOfflineReport(): number | null {
|
||||||
const r = offlineMinutes;
|
const r = offlineMinutes;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { GameState } from "./types";
|
||||||
|
import { engine } from "./engine";
|
||||||
|
|
||||||
|
/** 닉네임 사용 가능 여부 (null = 서버 확인 불가) */
|
||||||
|
export async function checkNickname(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ available: boolean; reason?: string } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/village/check?name=${encodeURIComponent(name)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as { available: boolean; reason?: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 선점 등록 */
|
||||||
|
export async function registerNickname(
|
||||||
|
nickname: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/village/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nickname, token }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return ((await res.json()) as { ok: boolean }).ok;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 상태를 서버에 저장 */
|
||||||
|
export async function pushSave(): Promise<void> {
|
||||||
|
const s = engine.peek();
|
||||||
|
if (!s?.nickname || !s.syncToken) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/village/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nickname: s.nickname,
|
||||||
|
token: s.syncToken,
|
||||||
|
data: s,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 오프라인이면 다음 주기에 재시도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 세이브를 가져와 로컬보다 최신이면 교체. 교체 여부 반환 */
|
||||||
|
export async function pullSave(): Promise<boolean> {
|
||||||
|
const s = engine.peek();
|
||||||
|
if (!s?.nickname || !s.syncToken) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/village/load?nickname=${encodeURIComponent(s.nickname)}&token=${encodeURIComponent(s.syncToken)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const { data } = (await res.json()) as { data: GameState | null };
|
||||||
|
if (data && data.version === 1 && data.updatedAt > s.updatedAt) {
|
||||||
|
engine.replaceState(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 무시 — 로컬로 계속
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newSyncToken(): string {
|
||||||
|
return (
|
||||||
|
Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export interface GameState {
|
|||||||
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
boardVersion?: number; // 보드 개편 마이그레이션 마커
|
||||||
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
tutorialStep?: number; // 튜토리얼 진행 (99 = 완료)
|
||||||
coins?: number; // 냥코인
|
coins?: number; // 냥코인
|
||||||
|
nickname?: string; // 유저 닉네임 (DB 동기화 키)
|
||||||
|
syncToken?: string; // 세이브 보호 토큰
|
||||||
happinessUnlocked?: boolean; // 행복도 시스템 해금 (회관 Lv3 + 햇볕 데크)
|
happinessUnlocked?: boolean; // 행복도 시스템 해금 (회관 Lv3 + 햇볕 데크)
|
||||||
happinessIntroSeen?: boolean; // 촌장 냥이 행복도 안내를 봤는가
|
happinessIntroSeen?: boolean; // 촌장 냥이 행복도 안내를 봤는가
|
||||||
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
decors?: DecoTile[]; // 꾸미기 바닥 타일
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
let tableReady = false;
|
||||||
|
|
||||||
|
/** villages 테이블 보장 (최초 1회) */
|
||||||
|
export async function ensureTable() {
|
||||||
|
if (tableReady) return;
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS villages (
|
||||||
|
nickname TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
data JSONB,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
tableReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
||||||
|
|
||||||
|
export function validNickname(name: unknown): name is string {
|
||||||
|
return typeof name === "string" && NICKNAME_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isAvailable(nickname: string): Promise<boolean> {
|
||||||
|
await ensureTable();
|
||||||
|
const r = await db.query("SELECT 1 FROM villages WHERE nickname = $1", [
|
||||||
|
nickname,
|
||||||
|
]);
|
||||||
|
return r.rowCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 선점 등록. 성공 여부 반환 */
|
||||||
|
export async function register(nickname: string, token: string): Promise<boolean> {
|
||||||
|
await ensureTable();
|
||||||
|
const r = await db.query(
|
||||||
|
`INSERT INTO villages (nickname, token, data)
|
||||||
|
VALUES ($1, $2, NULL)
|
||||||
|
ON CONFLICT (nickname) DO NOTHING`,
|
||||||
|
[nickname, token],
|
||||||
|
);
|
||||||
|
return (r.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세이브 저장 (토큰 일치 시에만) */
|
||||||
|
export async function saveVillage(
|
||||||
|
nickname: string,
|
||||||
|
token: string,
|
||||||
|
data: unknown,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await ensureTable();
|
||||||
|
const r = await db.query(
|
||||||
|
`UPDATE villages SET data = $3, updated_at = now()
|
||||||
|
WHERE nickname = $1 AND token = $2`,
|
||||||
|
[nickname, token, JSON.stringify(data)],
|
||||||
|
);
|
||||||
|
return (r.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세이브 불러오기 (토큰 일치 시에만) */
|
||||||
|
export async function loadVillage(
|
||||||
|
nickname: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<unknown | null> {
|
||||||
|
await ensureTable();
|
||||||
|
const r = await db.query(
|
||||||
|
"SELECT data FROM villages WHERE nickname = $1 AND token = $2",
|
||||||
|
[nickname, token],
|
||||||
|
);
|
||||||
|
return r.rows[0]?.data ?? null;
|
||||||
|
}
|
||||||
새 이슈에서 참조
사용자 차단