feat: 로또 추천 API, 텔레그램 봇 연동, 관리자 페이지 추가
- 로또 번호 추천 구독자 전용 페이지 (/services/lotto/recommend) - NAS 몬테카를로 API 연동 + 클라이언트 사이드 폴백 - 무료 미리보기 1개 + 구독자용 프리미엄 번호 추천 - 구독 플랜 변경: 골드(900원)/플래티넘(2,900원)/다이아(9,900원) - 텔레그램 봇 연동: 연결/해제, 웹훅, /start 명령 처리 - 마이페이지 텔레그램 연결 UI + 가이드 모달 - 관리자 페이지 (/admin): 대시보드, 회원, 서비스, 문의 관리 - Supabase 마이그레이션: profiles 텔레그램 컬럼, 신규 상품 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
lib/admin-auth.ts
Normal file
33
lib/admin-auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
const TOKEN_TTL = 24 * 60 * 60 * 1000; // 24시간
|
||||
|
||||
export function createAdminToken(): string {
|
||||
const secret = process.env.ADMIN_JWT_SECRET!;
|
||||
const payload = JSON.stringify({ iat: Date.now(), exp: Date.now() + TOKEN_TTL });
|
||||
const encoded = Buffer.from(payload).toString('base64url');
|
||||
const sig = createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
return `${encoded}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyAdminTokenNode(token: string): boolean {
|
||||
try {
|
||||
const secret = process.env.ADMIN_JWT_SECRET;
|
||||
if (!secret) return false;
|
||||
const [encoded, sig] = token.split('.');
|
||||
if (!encoded || !sig) return false;
|
||||
const expected = createHmac('sha256', secret).update(encoded).digest('base64url');
|
||||
if (sig !== expected) return false;
|
||||
const { exp } = JSON.parse(Buffer.from(encoded, 'base64url').toString());
|
||||
return Date.now() < exp;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkAdminCredentials(id: string, password: string): boolean {
|
||||
const adminId = process.env.ADMIN_ID;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
if (!adminId || !adminPassword) return false;
|
||||
return id === adminId && password === adminPassword;
|
||||
}
|
||||
@@ -7,26 +7,26 @@ export interface Product {
|
||||
}
|
||||
|
||||
export const PRODUCTS: Record<string, Product> = {
|
||||
lotto_basic: {
|
||||
id: 'lotto_basic',
|
||||
name: '로또 기본 플랜',
|
||||
price: 4900,
|
||||
lotto_gold: {
|
||||
id: 'lotto_gold',
|
||||
name: '로또 골드 플랜',
|
||||
price: 900,
|
||||
type: 'monthly',
|
||||
description: '매주 5개 번호 조합 이메일 제공',
|
||||
description: '매주 1회 번호 추천 · 이메일 발송',
|
||||
},
|
||||
lotto_premium: {
|
||||
id: 'lotto_premium',
|
||||
name: '로또 프리미엄 플랜',
|
||||
lotto_platinum: {
|
||||
id: 'lotto_platinum',
|
||||
name: '로또 플래티넘 플랜',
|
||||
price: 2900,
|
||||
type: 'monthly',
|
||||
description: '매주 3회 번호 + 텔레그램 알림 + 상세 분석',
|
||||
},
|
||||
lotto_diamond: {
|
||||
id: 'lotto_diamond',
|
||||
name: '로또 다이아 플랜',
|
||||
price: 9900,
|
||||
type: 'monthly',
|
||||
description: '매주 3회 번호 + 텔레그램 알림',
|
||||
},
|
||||
lotto_annual: {
|
||||
id: 'lotto_annual',
|
||||
name: '로또 연간 플랜',
|
||||
price: 89900,
|
||||
type: 'annual',
|
||||
description: '프리미엄 12개월 (2개월 무료)',
|
||||
description: '횟수 무제한 + 연간 패턴 리포트 + 전체 기능',
|
||||
},
|
||||
stock_starter_install: {
|
||||
id: 'stock_starter_install',
|
||||
|
||||
16
lib/supabase/admin.ts
Normal file
16
lib/supabase/admin.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
// 서비스 롤 키 사용 (RLS 우회, 서버 전용)
|
||||
export function createAdminClient() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!serviceKey) {
|
||||
// 서비스 롤 키 없으면 anon 키로 폴백 (RLS 제한 있음)
|
||||
return createSupabaseClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
|
||||
}
|
||||
|
||||
return createSupabaseClient(url, serviceKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
96
lib/telegram.ts
Normal file
96
lib/telegram.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Telegram Bot API 유틸리티
|
||||
* 환경변수: TELEGRAM_BOT_TOKEN
|
||||
*/
|
||||
|
||||
const BASE = () => {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) throw new Error('TELEGRAM_BOT_TOKEN이 설정되지 않았습니다.');
|
||||
return `https://api.telegram.org/bot${token}`;
|
||||
};
|
||||
|
||||
// ─── 메시지 전송 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function sendMessage(
|
||||
chatId: string | number,
|
||||
text: string,
|
||||
options: { parse_mode?: 'Markdown' | 'HTML'; disable_web_page_preview?: boolean } = {}
|
||||
): Promise<{ ok: boolean; description?: string }> {
|
||||
const res = await fetch(`${BASE()}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: options.parse_mode ?? 'Markdown',
|
||||
disable_web_page_preview: options.disable_web_page_preview ?? true,
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 로또 번호 알림 메시지 포맷 ──────────────────────────────────────────────
|
||||
|
||||
export function formatLottoMessage(
|
||||
numbers: number[],
|
||||
drawDate: string,
|
||||
planName: string,
|
||||
round?: number
|
||||
): string {
|
||||
const balls = numbers.map((n) => `*${String(n).padStart(2, '0')}*`).join(' ');
|
||||
const roundText = round ? ` (제${round}회 예상)` : '';
|
||||
|
||||
return [
|
||||
`🎰 *쟁승메이드 로또 번호 추천*${roundText}`,
|
||||
`📅 ${drawDate} | ${planName}`,
|
||||
``,
|
||||
`${balls}`,
|
||||
``,
|
||||
`📊 합계: ${numbers.reduce((a, b) => a + b, 0)} | 홀수: ${numbers.filter((n) => n % 2 !== 0).length}개`,
|
||||
``,
|
||||
`⚠️ 통계 기반 추천이며 당첨을 보장하지 않습니다.`,
|
||||
`🔗 [번호 추천 받기](https://jaengseung.com/services/lotto/recommend)`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── 웹훅 등록 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function setWebhook(
|
||||
webhookUrl: string,
|
||||
secretToken?: string
|
||||
): Promise<{ ok: boolean; description?: string }> {
|
||||
const body: Record<string, unknown> = {
|
||||
url: webhookUrl,
|
||||
allowed_updates: ['message'],
|
||||
};
|
||||
if (secretToken) body.secret_token = secretToken;
|
||||
|
||||
const res = await fetch(`${BASE()}/setWebhook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getWebhookInfo(): Promise<{ ok: boolean; result?: { url: string; pending_update_count: number } }> {
|
||||
const res = await fetch(`${BASE()}/getWebhookInfo`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Telegram Update 타입 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: {
|
||||
message_id: number;
|
||||
from?: {
|
||||
id: number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
};
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
date: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user