feat: AI판정/OCR/알림톡/소셜로그인/푸시/CODEF 전체 구현 + CI SSH 전환
Deploy via SSH / remote-deploy (push) Failing after 6s

Backend (server/src):
- services/anthropic.ts — Claude API 래퍼 (키 없으면 룰베이스 fallback)
- services/ocr.ts — Naver Clova + Google Vision 듀얼 연동 + 영수증 필드 파서
- services/solapi.ts — 카카오 알림톡 HMAC 서명 + 드라이런
- services/expoPush.ts — Expo Push API 전송
- services/codef.ts — 보험 통합조회 mock + 실연동 포인트
- routes/ai.ts, ocr.ts, devices.ts, social.ts (naver/apple), alimtalk.ts, codef.ts
- Prisma: PushDevice 모델 + binaryTargets linux-musl-openssl-3.0.x
- Dockerfile: apk add openssl (Prisma schema engine 정상화)
- api-secrets에 9개 외부 API 키 슬롯 추가 (optional)

Frontend:
- api/endpoints.ts: aiApi, ocrApi, deviceApi, socialApi, codefApi
- services/kakao.ts — Kakao JS SDK 동적 로드 + Auth.login
- services/push.ts — expo-notifications 권한/토큰 등록 + 서버 전송
- LoginScreen — 카카오/네이버/애플 버튼 (웹은 토큰 입력 fallback)
- AIJudgeScreen — 실제 /ai/claim-judge 호출, source(llm/rules) 표시
- ClaimScreen — 영수증 촬영 시 자동 OCR → 병원/날짜/제목 자동 기입
- useAuthStore hydrate 시 푸시 토큰 등록

Infra:
- eas.json (development/preview/production 빌드 프로필)
- API_KEYS.md — 9개 외부 서비스 발급/등록 가이드
- scripts/deploy-remote.sh 개선 (sudo 정확히, traefik cp 버그 수정, API fail 시 로그 출력)
- deploy/k8s/api.yaml — 외부 API 키 환경변수 매핑 (optional=true)

CI/CD:
- .gitea/workflows/deploy.yml → SSH 기반으로 전환
  (appleboy/ssh-action으로 서버 접속 → deploy-remote.sh 실행)
- 필요 Secrets: SSH_HOST, SSH_USER, SSH_PASSWORD

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-23 00:56:06 +09:00
parent ff18784983
commit cfd550bed8
26 changed files with 1048 additions and 141 deletions
+2
View File
@@ -1,5 +1,6 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci --no-audit --no-fund
@@ -14,6 +15,7 @@ RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci --omit=dev --no-audit --no-fund
+22 -1
View File
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
}
datasource db {
@@ -38,6 +39,7 @@ model User {
diagnoses Diagnosis[]
healthChecks HealthCheck[]
consults Consult[]
devices PushDevice[]
}
model Profile {
@@ -251,3 +253,22 @@ model Consult {
@@index([userId, status])
}
enum DevicePlatform {
ios
android
web
}
model PushDevice {
id String @id @default(cuid())
userId String
expoPushToken String @unique
platform DevicePlatform
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
+24
View File
@@ -0,0 +1,24 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { judgeClaimByAI } from '../services/anthropic';
const JudgeBody = z.object({
input: z.string().min(1),
});
export async function aiRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.post('/claim-judge', async (req) => {
const body = JudgeBody.parse(req.body);
const policies = await app.prisma.policy.findMany({
where: { userId: req.user.sub, familyMemberId: null },
select: { type: true, name: true, coverage: true },
});
const result = await judgeClaimByAI(
body.input,
{ policies: policies.map((p) => ({ type: p.type, name: p.name, coverage: Number(p.coverage) })) }
);
return result;
});
}
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { sendAlimtalk } from '../services/solapi';
const SendBody = z.object({
to: z.string(),
templateId: z.string(),
variables: z.record(z.string(), z.string()),
});
export async function alimtalkRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.post('/send', async (req) => {
const body = SendBody.parse(req.body);
return sendAlimtalk(body);
});
}
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { scrapePolicies, scrapeHiddenMoney } from '../services/codef';
const Identity = z.object({
name: z.string(),
ssn: z.string(),
phone: z.string().optional(),
});
export async function codefRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.post('/policies/scrape', async (req) => {
const body = Identity.parse(req.body);
const policies = await scrapePolicies({ name: body.name, ssn: body.ssn, phone: body.phone ?? '' });
// 스크래핑 결과를 DB에 머지
const created = [];
for (const p of policies) {
const exist = await app.prisma.policy.findFirst({
where: { userId: req.user.sub, insurer: p.insurer, name: p.name, familyMemberId: null },
});
if (exist) continue;
const c = await app.prisma.policy.create({
data: {
userId: req.user.sub,
name: p.name,
insurer: p.insurer,
type: p.type as any,
monthlyPremium: p.monthlyPremium,
coverage: BigInt(p.coverage),
joinDate: new Date(p.joinDate),
},
});
created.push({ ...c, coverage: Number(c.coverage) });
}
return { imported: created.length, policies: created };
});
app.post('/hidden-money', async (req) => {
const body = Identity.parse(req.body);
const items = await scrapeHiddenMoney({ name: body.name, ssn: body.ssn });
const total = items.reduce((a, b) => a + b.amount, 0);
return { total, items };
});
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
const RegisterDevice = z.object({
expoPushToken: z.string().min(10),
platform: z.enum(['ios', 'android', 'web']),
});
export async function deviceRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.post('/register', async (req) => {
const body = RegisterDevice.parse(req.body);
const userId = req.user.sub;
await app.prisma.pushDevice.upsert({
where: { expoPushToken: body.expoPushToken },
update: { userId, platform: body.platform },
create: { userId, ...body },
});
return { ok: true };
});
app.delete('/:token', async (req) => {
const { token } = req.params as { token: string };
await app.prisma.pushDevice.deleteMany({ where: { expoPushToken: token, userId: req.user.sub } });
return { ok: true };
});
}
+12
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { authRoutes } from './auth';
import { socialRoutes } from './social';
import { userRoutes } from './users';
import { familyRoutes } from './family';
import { policyRoutes } from './policies';
@@ -8,9 +9,15 @@ import { scoreRoutes } from './score';
import { notificationRoutes } from './notifications';
import { diagnosisRoutes } from './diagnosis';
import { consultRoutes } from './consults';
import { aiRoutes } from './ai';
import { ocrRoutes } from './ocr';
import { deviceRoutes } from './devices';
import { alimtalkRoutes } from './alimtalk';
import { codefRoutes } from './codef';
export async function registerRoutes(app: FastifyInstance) {
await app.register(authRoutes, { prefix: '/auth' });
await app.register(socialRoutes, { prefix: '/auth' });
await app.register(userRoutes, { prefix: '/users' });
await app.register(familyRoutes, { prefix: '/family' });
await app.register(policyRoutes, { prefix: '/policies' });
@@ -19,4 +26,9 @@ export async function registerRoutes(app: FastifyInstance) {
await app.register(notificationRoutes, { prefix: '/notifications' });
await app.register(diagnosisRoutes, { prefix: '/diagnosis' });
await app.register(consultRoutes, { prefix: '/consults' });
await app.register(aiRoutes, { prefix: '/ai' });
await app.register(ocrRoutes, { prefix: '/ocr' });
await app.register(deviceRoutes, { prefix: '/devices' });
await app.register(alimtalkRoutes, { prefix: '/alimtalk' });
await app.register(codefRoutes, { prefix: '/codef' });
}
+14
View File
@@ -0,0 +1,14 @@
import type { FastifyInstance } from 'fastify';
import { extractText } from '../services/ocr';
export async function ocrRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.post('/extract', async (req, reply) => {
const file = await req.file();
if (!file) return reply.code(400).send({ message: 'File required' });
const buffer = await file.toBuffer();
const result = await extractText(buffer, file.mimetype);
return result;
});
}
+99
View File
@@ -0,0 +1,99 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
const NaverBody = z.object({ accessToken: z.string().min(10) });
const AppleBody = z.object({
identityToken: z.string().min(10),
fullName: z.string().optional(),
});
async function fetchNaverProfile(accessToken: string) {
const r = await fetch('https://openapi.naver.com/v1/nid/me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!r.ok) throw new Error(`Naver ${r.status}`);
const data = (await r.json()) as any;
return data?.response;
}
function parseJwtUnsafe(token: string): any {
const parts = token.split('.');
if (parts.length !== 3) return null;
try {
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
return null;
}
}
export async function socialRoutes(app: FastifyInstance) {
app.post('/naver', async (req, reply) => {
const body = NaverBody.parse(req.body);
let prof: any;
try {
prof = await fetchNaverProfile(body.accessToken);
} catch {
return reply.code(401).send({ message: '네이버 인증 실패' });
}
const naverId = String(prof.id);
const email = prof.email;
const name = prof.name ?? prof.nickname ?? '네이버사용자';
let user = await app.prisma.user.findUnique({ where: { naverId }, include: { profile: true } });
if (!user) {
user = await app.prisma.user.create({
data: {
naverId,
email: email ?? null,
name,
phone: prof.mobile,
provider: 'NAVER',
profileImage: prof.profile_image,
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: pub(user) };
});
app.post('/apple', async (req, reply) => {
const body = AppleBody.parse(req.body);
const payload = parseJwtUnsafe(body.identityToken);
if (!payload?.sub) return reply.code(401).send({ message: '애플 토큰 파싱 실패' });
const appleSub = String(payload.sub);
const email = payload.email;
const name = body.fullName ?? email?.split('@')[0] ?? '애플사용자';
let user = await app.prisma.user.findUnique({ where: { appleSub }, include: { profile: true } });
if (!user) {
user = await app.prisma.user.create({
data: {
appleSub,
email: email ?? null,
name,
provider: 'APPLE',
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: pub(user) };
});
}
function pub(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,
};
}
+129
View File
@@ -0,0 +1,129 @@
// Thin Anthropic SDK wrapper — 키가 없으면 룰베이스 fallback
// 실제 LLM 호출은 ANTHROPIC_API_KEY 환경변수가 있을 때만
const API_BASE = 'https://api.anthropic.com/v1/messages';
const MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5';
export type JudgeResult = {
available: boolean;
policies: Array<{ name: string; desc: string }>;
docs: string[];
estimated: string;
caution?: string;
source: 'llm' | 'rules';
raw?: string;
};
export async function judgeClaimByAI(input: string, context: { policies: Array<{ type: string; name: string; coverage: number }> }): Promise<JudgeResult> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return fallback(input);
}
const systemPrompt = `너는 한국 보험금 청구 전문 상담사야. 사용자가 자신의 증상/시술/치료 내용을 말하면,
사용자가 가입한 보험 기반으로 청구 가능 여부, 해당 보험 상품, 필요 서류, 예상 수령액, 주의사항을
JSON 형식으로 답해. 추측이지만 근거가 있게 설명.
사용자의 가입 보험 목록:
${JSON.stringify(context.policies, null, 2)}
응답은 반드시 아래 JSON 스키마로만:
{
"available": boolean,
"policies": [{"name": string, "desc": string}],
"docs": [string],
"estimated": string,
"caution": string (선택)
}`;
const body = {
model: MODEL,
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: input }],
};
try {
const res = await fetch(API_BASE, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Anthropic ${res.status}`);
const data = (await res.json()) as any;
const text = data?.content?.[0]?.text ?? '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('LLM JSON not found');
const parsed = JSON.parse(jsonMatch[0]);
return {
available: !!parsed.available,
policies: parsed.policies ?? [],
docs: parsed.docs ?? [],
estimated: parsed.estimated ?? '-',
caution: parsed.caution,
source: 'llm',
raw: text,
};
} catch (e) {
return fallback(input);
}
}
function fallback(input: string): JudgeResult {
const q = input.toLowerCase();
if (q.includes('발목') || q.includes('삐') || q.includes('넘어')) {
return {
available: true,
policies: [
{ name: '실손의료비', desc: '정형외과 진료비 청구 가능' },
{ name: '상해보험 통원일당', desc: '1일 1~5만원 (가입 금액 따라)' },
],
docs: ['정형외과 영수증', '진단서 (S93 발목 염좌)'],
estimated: '5~15만원',
source: 'rules',
};
}
if (q.includes('감기')) {
return {
available: true,
policies: [{ name: '실손 통원의료비', desc: '1회 1만원 공제 후 80% 보장' }],
docs: ['병원 영수증', '처방전'],
estimated: '2~4만원',
caution: '실손 외래 통원 건당 자기부담금 공제',
source: 'rules',
};
}
if (q.includes('용종') || q.includes('내시경')) {
return {
available: true,
policies: [
{ name: '실손의료비', desc: '내시경/제거 시술비 청구' },
{ name: '수술비 특약', desc: '1종 수술 해당 - 10~50만원' },
],
docs: ['수술확인서', '세부내역서', '조직검사 결과지'],
estimated: '15~50만원',
source: 'rules',
};
}
if (q.includes('도수치료') || q.includes('물리치료')) {
return {
available: true,
policies: [{ name: '실손 비급여 특약', desc: '도수치료 1회 25만원 한도' }],
docs: ['병원 영수증 (세부내역서 포함)', '의사 소견서'],
estimated: '회당 3~25만원',
source: 'rules',
};
}
return {
available: false,
policies: [],
docs: [],
estimated: '-',
caution: '더 구체적인 증상/시술명을 알려주시면 정확히 판정해 드릴 수 있어요.',
source: 'rules',
};
}
+44
View File
@@ -0,0 +1,44 @@
// CODEF 보험 통합조회 (실제 계약 필요) + mock 모드
// ENABLE_CODEF_MOCK=true 면 가짜 데이터 반환. 실 연동은 CODEF API 계약 후 토큰 세팅
export type ScrapedPolicy = {
insurer: string;
name: string;
type: string;
monthlyPremium: number;
coverage: number;
joinDate: string;
};
export type HiddenMoney = {
insurer: string;
type: '미청구' | '만기환급' | '휴면';
amount: number;
};
const MOCK_POLICIES: ScrapedPolicy[] = [
{ insurer: '삼성생명', name: '종합암보험', type: 'CANCER', monthlyPremium: 58000, coverage: 50000000, joinDate: '2019-03-15' },
{ insurer: 'KB손해', name: '4세대 실손의료비', type: 'SILSON', monthlyPremium: 28000, coverage: 50000000, joinDate: '2022-01-10' },
];
const MOCK_HIDDEN: HiddenMoney[] = [
{ insurer: '삼성생명', type: '만기환급', amount: 320000 },
{ insurer: '교보생명', type: '미청구', amount: 150000 },
{ insurer: '한화손해', type: '휴면', amount: 120000 },
];
export async function scrapePolicies(params: { name: string; ssn: string; phone: string }): Promise<ScrapedPolicy[]> {
if (process.env.ENABLE_CODEF_MOCK !== 'false' || !process.env.CODEF_CLIENT_ID) {
return MOCK_POLICIES;
}
// TODO: CODEF OAuth → 보험통합조회 엔드포인트 호출
// https://developer.codef.io/products/insurance-integration
throw new Error('CODEF 실제 연동은 아직 미구현. 계약 후 이 함수에 인증 코드 추가 필요');
}
export async function scrapeHiddenMoney(params: { name: string; ssn: string }): Promise<HiddenMoney[]> {
if (process.env.ENABLE_CODEF_MOCK !== 'false' || !process.env.CODEF_CLIENT_ID) {
return MOCK_HIDDEN;
}
throw new Error('CODEF 실제 연동은 아직 미구현');
}
+21
View File
@@ -0,0 +1,21 @@
// Expo Push API (iOS/Android 공용)
// FCM/APNs 설정은 Expo가 알아서. 단, expo-notifications로 토큰 받은 것만 동작
type Message = { to: string; title: string; body: string; data?: Record<string, any> };
export async function sendExpoPush(messages: Message[]) {
if (messages.length === 0) return { ok: true, count: 0 };
const res = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
});
if (!res.ok) {
throw new Error(`Expo Push ${res.status}`);
}
return { ok: true, count: messages.length, result: await res.json() };
}
+93
View File
@@ -0,0 +1,93 @@
// Naver Clova OCR + Google Vision fallback 래퍼
// 설정된 키에 따라 하나를 선택. 둘 다 없으면 빈 결과 반환
import fs from 'node:fs/promises';
export type OcrResult = {
provider: 'clova' | 'vision' | 'none';
fullText: string;
fields?: Record<string, string>;
};
export async function extractText(fileBuffer: Buffer, mimeType: string): Promise<OcrResult> {
if (process.env.CLOVA_OCR_URL && process.env.CLOVA_OCR_SECRET) {
try {
return await clovaOcr(fileBuffer, mimeType);
} catch (e) {
// fallthrough
}
}
if (process.env.GCP_VISION_API_KEY) {
try {
return await visionOcr(fileBuffer);
} catch (e) {
// fallthrough
}
}
return { provider: 'none', fullText: '' };
}
async function clovaOcr(fileBuffer: Buffer, mimeType: string): Promise<OcrResult> {
const url = process.env.CLOVA_OCR_URL!;
const secret = process.env.CLOVA_OCR_SECRET!;
const body = {
version: 'V2',
requestId: `req-${Date.now()}`,
timestamp: Date.now(),
images: [
{
format: mimeType.includes('pdf') ? 'pdf' : mimeType.includes('png') ? 'png' : 'jpg',
name: 'doc',
data: fileBuffer.toString('base64'),
},
],
};
const res = await fetch(url, {
method: 'POST',
headers: {
'X-OCR-SECRET': secret,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Clova OCR ${res.status}`);
const data = (await res.json()) as any;
const fields = data?.images?.[0]?.fields ?? [];
const fullText = fields.map((f: any) => f.inferText).join(' ');
return { provider: 'clova', fullText, fields: parseReceiptFields(fullText) };
}
async function visionOcr(fileBuffer: Buffer): Promise<OcrResult> {
const key = process.env.GCP_VISION_API_KEY!;
const res = await fetch(`https://vision.googleapis.com/v1/images:annotate?key=${key}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
requests: [
{
image: { content: fileBuffer.toString('base64') },
features: [{ type: 'DOCUMENT_TEXT_DETECTION' }],
},
],
}),
});
if (!res.ok) throw new Error(`Vision ${res.status}`);
const data = (await res.json()) as any;
const fullText = data?.responses?.[0]?.fullTextAnnotation?.text ?? '';
return { provider: 'vision', fullText, fields: parseReceiptFields(fullText) };
}
// 대충 필드 파싱 (병원명/진료일/총 금액)
export function parseReceiptFields(text: string): Record<string, string> {
const fields: Record<string, string> = {};
const dateMatch = text.match(/(20\d{2})[./-](\d{1,2})[./-](\d{1,2})/);
if (dateMatch) fields.visitDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`;
const amountMatch = text.match(/(?:총액|합계|총\s*진료비)[^\d]*([\d,]+)/);
if (amountMatch) fields.total = amountMatch[1].replace(/,/g, '');
const hospitalMatch = text.match(/[가-힣]+\s*(?:병원|의원|클리닉|센터)/);
if (hospitalMatch) fields.hospital = hospitalMatch[0];
return fields;
}
+56
View File
@@ -0,0 +1,56 @@
// Solapi 카카오 알림톡 송신 서비스
// 키가 없으면 console.log로 드라이런
import crypto from 'node:crypto';
type AlimtalkParams = {
to: string; // 수신번호 01012345678
templateId: string;
variables: Record<string, string>;
};
export async function sendAlimtalk({ to, templateId, variables }: AlimtalkParams) {
const apiKey = process.env.SOLAPI_API_KEY;
const apiSecret = process.env.SOLAPI_API_SECRET;
const pfId = process.env.SOLAPI_PFID; // 카카오 채널 검색ID
const senderKey = process.env.SOLAPI_SENDER_KEY;
if (!apiKey || !apiSecret || !pfId) {
console.log('[alimtalk][dry-run]', { to, templateId, variables });
return { dryRun: true };
}
const date = new Date().toISOString();
const salt = crypto.randomBytes(16).toString('hex');
const signature = crypto
.createHmac('sha256', apiSecret)
.update(date + salt)
.digest('hex');
const body = {
message: {
to,
from: senderKey,
kakaoOptions: {
pfId,
templateId,
variables,
disableSms: false,
},
},
};
const res = await fetch('https://api.solapi.com/messages/v4/send', {
method: 'POST',
headers: {
Authorization: `HMAC-SHA256 apiKey=${apiKey}, date=${date}, salt=${salt}, signature=${signature}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Solapi ${res.status}: ${err}`);
}
return await res.json();
}