import type { FastifyInstance } from 'fastify'; import bcrypt from 'bcrypt'; import { z } from 'zod'; const RegisterBody = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().min(1), phone: z.string().optional(), age: z.number().int().min(0).max(120), gender: z.enum(['MALE', 'FEMALE']), job: z.string().optional(), }); const LoginBody = z.object({ email: z.string().email(), password: z.string().min(1), }); const KakaoBody = z.object({ accessToken: z.string().min(10), }); type KakaoMe = { id: number; kakao_account?: { email?: string; profile?: { nickname?: string; profile_image_url?: string }; phone_number?: string; }; properties?: { nickname?: string; profile_image?: string }; }; async function fetchKakaoProfile(accessToken: string): Promise { const res = await fetch('https://kapi.kakao.com/v2/user/me', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) throw new Error(`Kakao profile fetch failed: ${res.status}`); return (await res.json()) as KakaoMe; } export async function authRoutes(app: FastifyInstance) { app.post('/register', async (req, reply) => { const body = RegisterBody.parse(req.body); const exists = await app.prisma.user.findUnique({ where: { email: body.email } }); if (exists) return reply.code(409).send({ message: '이미 가입된 이메일입니다' }); const passwordHash = await bcrypt.hash(body.password, 10); const user = await app.prisma.user.create({ data: { email: body.email, passwordHash, name: body.name, phone: body.phone, provider: 'EMAIL', profile: { create: { age: body.age, gender: body.gender, job: body.job ?? '기타', }, }, }, include: { profile: true }, }); const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined }); return { token, user: publicUser(user) }; }); app.post('/login', async (req, reply) => { const body = LoginBody.parse(req.body); const user = await app.prisma.user.findUnique({ where: { email: body.email }, include: { profile: true }, }); if (!user || !user.passwordHash) return reply.code(401).send({ message: '이메일 또는 비밀번호가 틀렸습니다' }); const ok = await bcrypt.compare(body.password, user.passwordHash); if (!ok) return reply.code(401).send({ message: '이메일 또는 비밀번호가 틀렸습니다' }); const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined }); return { token, user: publicUser(user) }; }); app.post('/kakao', async (req, reply) => { const body = KakaoBody.parse(req.body); let me: KakaoMe; try { me = await fetchKakaoProfile(body.accessToken); } catch (e) { return reply.code(401).send({ message: '카카오 인증 실패' }); } const kakaoId = String(me.id); const email = me.kakao_account?.email; const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자'; const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image; let user = await app.prisma.user.findUnique({ where: { kakaoId }, include: { profile: true }, }); if (!user) { user = await app.prisma.user.create({ data: { kakaoId, email: email ?? null, name, phone: me.kakao_account?.phone_number, provider: 'KAKAO', profileImage, profile: { create: { age: 30, gender: 'MALE', job: '기타', }, }, }, include: { profile: true }, }); } const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined }); return { token, user: publicUser(user) }; }); app.get('/me', { onRequest: [app.authenticate] }, async (req) => { const user = await app.prisma.user.findUnique({ where: { id: req.user.sub }, include: { profile: true }, }); if (!user) throw new Error('User not found'); return publicUser(user); }); } function publicUser(u: any) { return { id: u.id, email: u.email, name: u.name, phone: u.phone, provider: u.provider, profileImage: u.profileImage, profile: u.profile ? { age: u.profile.age, gender: u.profile.gender, job: u.profile.job, monthlyPremium: u.profile.monthlyPremium, score: u.profile.score, } : null, }; }