Files
insurance/server/src/routes/auth.ts
T
chpark f78949c21a
Build & Deploy / build-and-deploy (push) Failing after 9s
feat: 실제 동작하는 백엔드 + DB + 카카오 로그인
Backend (server/):
- Fastify + Prisma + PostgreSQL 16
- JWT 인증 (bcrypt) + 카카오 OAuth (/auth/kakao — kapi.kakao.com 호출)
- REST API: auth, users, family, policies, claims, score, notifications, diagnosis, consults
- 실제 보험점수 알고리즘 (카테고리별 가중치·최소보장 기반)
- Multipart 업로드 (영수증/진단서 → 디스크 persistence)
- Swagger UI /docs

Client:
- api/client.ts + api/endpoints.ts (fetch 래퍼 + AsyncStorage 토큰)
- 인증 스토어 (hydrate/login/register/kakao/logout)
- 로그인/회원가입 화면 + 카카오 버튼
- 홈/내보험/가족/점수/청구 API 연동 (pull-to-refresh)
- 보험 추가 모달 + 가족 구성원 추가 모달
- 로그인 전/후 스택 분기 (RootNavigator)

Infra:
- docker-compose.yml (로컬 Postgres+API)
- server/Dockerfile (Prisma migrate deploy + node)
- deploy/k8s/postgres.yaml (StatefulSet + 10Gi PVC)
- deploy/k8s/api.yaml (Deployment + Ingress api.insurance.junggomoa.com)
- CI workflow 확장 (web + api 동시 빌드·배포)
- POSTGRES_PASSWORD / JWT_SECRET Gitea Secrets 추가 필요
- 반응형 웹 레이아웃 (max-width 480px 폰 프레임)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:32:44 +09:00

160 lines
4.6 KiB
TypeScript

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<KakaoMe> {
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,
};
}