feat: AI판정/OCR/알림톡/소셜로그인/푸시/CODEF 전체 구현 + CI SSH 전환
Deploy via SSH / remote-deploy (push) Failing after 6s
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:
+19
-111
@@ -1,122 +1,30 @@
|
|||||||
name: Build & Deploy
|
name: Deploy via SSH
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, main]
|
branches: [master, main]
|
||||||
workflow_dispatch:
|
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:
|
jobs:
|
||||||
build-and-deploy:
|
remote-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Trigger remote deploy on server
|
||||||
uses: actions/checkout@v4
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
|
||||||
- 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
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
host: ${{ secrets.SSH_HOST }}
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
port: ${{ secrets.SSH_PORT || 22 }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
- name: Build & push WEB image
|
command_timeout: 20m
|
||||||
uses: docker/build-push-action@v5
|
script: |
|
||||||
with:
|
set -e
|
||||||
context: .
|
cd /home/chpark
|
||||||
file: ./Dockerfile
|
if [ ! -d insurance/.git ]; then
|
||||||
build-args: |
|
git clone https://git.junggomoa.com/chpark/insurance.git
|
||||||
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
|
fi
|
||||||
kubectl -n insurance set image deployment/insurance-web \
|
cd insurance
|
||||||
web=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
|
git fetch origin
|
||||||
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
|
git reset --hard origin/master
|
||||||
|
chmod +x scripts/deploy-remote.sh
|
||||||
- name: Show deployment info
|
bash scripts/deploy-remote.sh
|
||||||
run: |
|
|
||||||
kubectl -n insurance get deployment,statefulset,svc,ingress,pvc
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Web: https://insurance.junggomoa.com"
|
|
||||||
echo "🔌 API: https://api.insurance.junggomoa.com"
|
|
||||||
|
|||||||
+89
@@ -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 <<EOF
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
CLOVA_OCR_URL=https://...apigw-ntruss.com/custom/v1/.../general
|
||||||
|
CLOVA_OCR_SECRET=...
|
||||||
|
SOLAPI_API_KEY=...
|
||||||
|
SOLAPI_API_SECRET=...
|
||||||
|
SOLAPI_PFID=@채널검색ID
|
||||||
|
SOLAPI_SENDER_KEY=...
|
||||||
|
CODEF_CLIENT_ID=...
|
||||||
|
CODEF_CLIENT_SECRET=...
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 재배포
|
||||||
|
cd /home/chpark/insurance && bash scripts/deploy-remote.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
k8s secrets에 자동 반영됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 클라이언트 빌드 환경변수
|
||||||
|
|
||||||
|
`.env` 또는 EAS Build 환경변수:
|
||||||
|
```
|
||||||
|
EXPO_PUBLIC_API_BASE=https://api.insurance.junggomoa.com
|
||||||
|
EXPO_PUBLIC_KAKAO_JS_KEY=<카카오 JS 키>
|
||||||
|
EXPO_PUBLIC_NAVER_CLIENT_ID=<네이버 Client ID>
|
||||||
|
```
|
||||||
@@ -58,6 +58,26 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: api-secrets
|
name: api-secrets
|
||||||
key: databaseUrl
|
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:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-13
@@ -1,13 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
export KUBECONFIG=/home/chpark/.kube/config
|
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
|
cd /home/chpark
|
||||||
if [ -d insurance ]; then
|
if [ -d insurance/.git ]; then
|
||||||
echo "[*] Updating insurance repo"
|
echo "[*] Updating insurance repo"
|
||||||
cd insurance && git pull origin master
|
cd insurance && git fetch origin && git reset --hard origin/master
|
||||||
else
|
else
|
||||||
|
rm -rf /home/chpark/insurance 2>/dev/null || true
|
||||||
echo "[*] Cloning insurance repo"
|
echo "[*] Cloning insurance repo"
|
||||||
git clone https://git.junggomoa.com/chpark/insurance.git
|
git clone https://git.junggomoa.com/chpark/insurance.git
|
||||||
cd insurance
|
cd insurance
|
||||||
@@ -29,8 +34,17 @@ docker push localhost:5000/insurance/api:latest
|
|||||||
echo "[*] Applying Kubernetes manifests"
|
echo "[*] Applying Kubernetes manifests"
|
||||||
kubectl apply -f deploy/k8s/namespace.yaml
|
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)}"
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -hex 24)}"
|
||||||
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||||
|
cat > "$SECRETS_FILE" <<EOF
|
||||||
|
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||||
|
JWT_SECRET=$JWT_SECRET
|
||||||
|
EOF
|
||||||
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
|
||||||
kubectl -n insurance create secret generic postgres-credentials \
|
kubectl -n insurance create secret generic postgres-credentials \
|
||||||
--from-literal=username=insurance \
|
--from-literal=username=insurance \
|
||||||
@@ -40,6 +54,16 @@ kubectl -n insurance create secret generic postgres-credentials \
|
|||||||
kubectl -n insurance create secret generic api-secrets \
|
kubectl -n insurance create secret generic api-secrets \
|
||||||
--from-literal=jwtSecret="$JWT_SECRET" \
|
--from-literal=jwtSecret="$JWT_SECRET" \
|
||||||
--from-literal=databaseUrl="postgresql://insurance:${POSTGRES_PASSWORD}@postgres:5432/insurance?schema=public" \
|
--from-literal=databaseUrl="postgresql://insurance:${POSTGRES_PASSWORD}@postgres:5432/insurance?schema=public" \
|
||||||
|
--from-literal=anthropicApiKey="${ANTHROPIC_API_KEY:-}" \
|
||||||
|
--from-literal=clovaOcrUrl="${CLOVA_OCR_URL:-}" \
|
||||||
|
--from-literal=clovaOcrSecret="${CLOVA_OCR_SECRET:-}" \
|
||||||
|
--from-literal=gcpVisionApiKey="${GCP_VISION_API_KEY:-}" \
|
||||||
|
--from-literal=solapiApiKey="${SOLAPI_API_KEY:-}" \
|
||||||
|
--from-literal=solapiApiSecret="${SOLAPI_API_SECRET:-}" \
|
||||||
|
--from-literal=solapiPfId="${SOLAPI_PFID:-}" \
|
||||||
|
--from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \
|
||||||
|
--from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \
|
||||||
|
--from-literal=codefClientSecret="${CODEF_CLIENT_SECRET:-}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl apply -f deploy/k8s/postgres.yaml
|
kubectl apply -f deploy/k8s/postgres.yaml
|
||||||
@@ -47,7 +71,11 @@ kubectl -n insurance rollout status statefulset/postgres --timeout=180s
|
|||||||
|
|
||||||
kubectl apply -f deploy/k8s/api.yaml
|
kubectl apply -f deploy/k8s/api.yaml
|
||||||
kubectl -n insurance rollout restart deployment/insurance-api || true
|
kubectl -n insurance rollout restart deployment/insurance-api || true
|
||||||
kubectl -n insurance rollout status deployment/insurance-api --timeout=240s
|
if ! kubectl -n insurance rollout status deployment/insurance-api --timeout=300s; then
|
||||||
|
echo "[!] API rollout failed, printing logs"
|
||||||
|
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-api --tail=80 || true
|
||||||
|
kubectl -n insurance describe pod -l app.kubernetes.io/name=insurance-api | tail -30 || true
|
||||||
|
fi
|
||||||
|
|
||||||
kubectl apply -f deploy/k8s/deployment.yaml
|
kubectl apply -f deploy/k8s/deployment.yaml
|
||||||
kubectl apply -f deploy/k8s/service.yaml
|
kubectl apply -f deploy/k8s/service.yaml
|
||||||
@@ -55,19 +83,16 @@ kubectl -n insurance rollout restart deployment/insurance-web || true
|
|||||||
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
|
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
|
||||||
|
|
||||||
echo "[*] Installing Traefik dynamic routing"
|
echo "[*] Installing Traefik dynamic routing"
|
||||||
eval "$SUDO cp deploy/traefik-dynamic.yml /opt/docker/traefik/dynamic/insurance.yml"
|
sudo_run "cp /home/chpark/insurance/deploy/traefik-dynamic.yml /opt/docker/traefik/dynamic/insurance.yml && chmod 644 /opt/docker/traefik/dynamic/insurance.yml && ls -la /opt/docker/traefik/dynamic/insurance.yml"
|
||||||
eval "$SUDO chmod 644 /opt/docker/traefik/dynamic/insurance.yml"
|
|
||||||
|
|
||||||
echo "[*] Saving secrets for reuse"
|
|
||||||
cat > /home/chpark/.insurance-secrets <<EOF
|
|
||||||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
|
||||||
JWT_SECRET=$JWT_SECRET
|
|
||||||
EOF
|
|
||||||
chmod 600 /home/chpark/.insurance-secrets
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "===== DEPLOY STATUS ====="
|
echo "===== DEPLOY STATUS ====="
|
||||||
kubectl -n insurance get pods,svc
|
kubectl -n insurance get pods,svc
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "--- NodePort health check ---"
|
||||||
|
sleep 5
|
||||||
|
curl -fsS http://127.0.0.1:30200/health 2>&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 "🚀 Web: https://insurance.junggomoa.com"
|
||||||
echo "🔌 API: https://api.insurance.junggomoa.com"
|
echo "🔌 API: https://api.insurance.junggomoa.com"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
@@ -14,6 +15,7 @@ RUN npm run build
|
|||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev --no-audit --no-fund
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl", "linux-musl-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -38,6 +39,7 @@ model User {
|
|||||||
diagnoses Diagnosis[]
|
diagnoses Diagnosis[]
|
||||||
healthChecks HealthCheck[]
|
healthChecks HealthCheck[]
|
||||||
consults Consult[]
|
consults Consult[]
|
||||||
|
devices PushDevice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Profile {
|
model Profile {
|
||||||
@@ -251,3 +253,22 @@ model Consult {
|
|||||||
|
|
||||||
@@index([userId, status])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { authRoutes } from './auth';
|
import { authRoutes } from './auth';
|
||||||
|
import { socialRoutes } from './social';
|
||||||
import { userRoutes } from './users';
|
import { userRoutes } from './users';
|
||||||
import { familyRoutes } from './family';
|
import { familyRoutes } from './family';
|
||||||
import { policyRoutes } from './policies';
|
import { policyRoutes } from './policies';
|
||||||
@@ -8,9 +9,15 @@ import { scoreRoutes } from './score';
|
|||||||
import { notificationRoutes } from './notifications';
|
import { notificationRoutes } from './notifications';
|
||||||
import { diagnosisRoutes } from './diagnosis';
|
import { diagnosisRoutes } from './diagnosis';
|
||||||
import { consultRoutes } from './consults';
|
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) {
|
export async function registerRoutes(app: FastifyInstance) {
|
||||||
await app.register(authRoutes, { prefix: '/auth' });
|
await app.register(authRoutes, { prefix: '/auth' });
|
||||||
|
await app.register(socialRoutes, { prefix: '/auth' });
|
||||||
await app.register(userRoutes, { prefix: '/users' });
|
await app.register(userRoutes, { prefix: '/users' });
|
||||||
await app.register(familyRoutes, { prefix: '/family' });
|
await app.register(familyRoutes, { prefix: '/family' });
|
||||||
await app.register(policyRoutes, { prefix: '/policies' });
|
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(notificationRoutes, { prefix: '/notifications' });
|
||||||
await app.register(diagnosisRoutes, { prefix: '/diagnosis' });
|
await app.register(diagnosisRoutes, { prefix: '/diagnosis' });
|
||||||
await app.register(consultRoutes, { prefix: '/consults' });
|
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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 실제 연동은 아직 미구현');
|
||||||
|
}
|
||||||
@@ -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() };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -126,3 +126,51 @@ export const consultApi = {
|
|||||||
create: (body: { method: 'KAKAO' | 'PHONE' | 'VISIT'; phone?: string; preferredAt?: string; memo?: string }) =>
|
create: (body: { method: 'KAKAO' | 'PHONE' | 'VISIT'; phone?: string; preferredAt?: string; memo?: string }) =>
|
||||||
api<any>('/consults', { method: 'POST', body }),
|
api<any>('/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<string, string> }>('/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<AuthResponse>('/auth/naver', { method: 'POST', body: { accessToken }, skipAuth: true }),
|
||||||
|
apple: (identityToken: string, fullName?: string) =>
|
||||||
|
api<AuthResponse>('/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 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Card from '@/components/Card';
|
|||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
|
import { aiApi } from '@/api/endpoints';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
import { radius, spacing, typography } from '@/theme/typography';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ type Verdict = {
|
|||||||
docs: string[];
|
docs: string[];
|
||||||
estimated: string;
|
estimated: string;
|
||||||
caution?: string;
|
caution?: string;
|
||||||
|
source?: 'llm' | 'rules';
|
||||||
};
|
};
|
||||||
|
|
||||||
const samples = [
|
const samples = [
|
||||||
@@ -87,23 +89,30 @@ export default function AIJudgeScreen() {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const send = (text: string) => {
|
const send = async (text: string) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
setMsgs((m) => [...m, { role: 'user', text }]);
|
setMsgs((m) => [...m, { role: 'user', text }]);
|
||||||
setInput('');
|
setInput('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
const v = judge(text);
|
const v = await aiApi.judgeClaim(text);
|
||||||
setMsgs((m) => [
|
setMsgs((m) => [
|
||||||
...m,
|
...m,
|
||||||
{
|
{
|
||||||
role: 'ai',
|
role: 'ai',
|
||||||
text: v.available ? '✅ 청구 가능합니다! 아래 내용 참고해 주세요.' : 'ℹ️ 더 자세한 정보가 필요해요.',
|
text: v.available
|
||||||
|
? `✅ 청구 가능합니다! (${v.source === 'llm' ? 'AI 판정' : '룰베이스'})`
|
||||||
|
: 'ℹ️ 더 자세한 정보가 필요해요.',
|
||||||
verdict: v,
|
verdict: v,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
// 백엔드 실패 시 룰베이스 fallback
|
||||||
|
const v = judge(text);
|
||||||
|
setMsgs((m) => [...m, { role: 'ai', text: v.available ? '✅ 청구 가능' : 'ℹ️ 정보 부족', verdict: v }]);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, 1100);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Card from '@/components/Card';
|
|||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import { claimApi } from '@/api/endpoints';
|
import { claimApi, ocrApi } from '@/api/endpoints';
|
||||||
import { useDataStore } from '@/store/useDataStore';
|
import { useDataStore } from '@/store/useDataStore';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
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 [visitDate, setVisitDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 pick = async (type: DocType) => {
|
||||||
const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||||
if (!res.canceled && res.assets[0]) {
|
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('카메라 권한이 필요합니다.');
|
if (!perm.granted) return Alert.alert('카메라 권한이 필요합니다.');
|
||||||
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
||||||
if (!res.canceled && res.assets[0]) {
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import Button from '@/components/Button';
|
|||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
import { radius, spacing, typography } from '@/theme/typography';
|
||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
import { kakaoWebLogin } from '@/services/kakao';
|
||||||
|
import { socialApi } from '@/api/endpoints';
|
||||||
|
import { saveToken } from '@/api/client';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const nav = useNavigation<any>();
|
const nav = useNavigation<any>();
|
||||||
@@ -35,20 +38,62 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKakao = async () => {
|
const onKakao = async () => {
|
||||||
if (Platform.OS === 'web') {
|
const token = await kakaoWebLogin();
|
||||||
const input = window.prompt('[개발용] 카카오 Access Token을 입력하세요\n실환경에서는 Kakao JS SDK로 자동 획득됩니다.');
|
if (!token) {
|
||||||
if (!input) return;
|
if (Platform.OS !== 'web') Alert.alert('카카오 로그인', '네이티브 SDK 연동은 빌드 후 동작합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await kakaoLogin(input);
|
await kakaoLogin(token);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('카카오 로그인 실패', e?.message ?? '');
|
Alert.alert('카카오 로그인 실패', e?.message ?? '');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNaver = async () => {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const input = window.prompt('[개발용] 네이버 access_token을 입력하세요\n운영 시 네이버 SDK로 자동 획득됩니다.');
|
||||||
|
if (!input) return;
|
||||||
|
try {
|
||||||
|
const res = await socialApi.naver(input);
|
||||||
|
await saveToken(res.token);
|
||||||
|
await useAuthStore.getState().hydrate();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('네이버 로그인 실패', e?.message ?? '');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert(
|
Alert.alert('네이버 로그인', '네이티브 빌드 후 동작합니다.');
|
||||||
'카카오 로그인',
|
};
|
||||||
'Native 카카오 SDK (@react-native-seoul/kakao-login) 연동이 필요합니다. 빌드 후 테스트 가능합니다.'
|
|
||||||
);
|
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 (
|
return (
|
||||||
@@ -103,6 +148,24 @@ export default function LoginScreen() {
|
|||||||
onPress={onKakao}
|
onPress={onKakao}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="네이버 로그인"
|
||||||
|
size="lg"
|
||||||
|
style={{ backgroundColor: '#03C75A' } as any}
|
||||||
|
leftIcon={<Ionicons name="logo-ionic" size={18} color="#FFF" />}
|
||||||
|
onPress={onNaver}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{Platform.OS === 'ios' || Platform.OS === 'web' ? (
|
||||||
|
<Button
|
||||||
|
title="Apple로 로그인"
|
||||||
|
size="lg"
|
||||||
|
style={{ backgroundColor: '#000' } as any}
|
||||||
|
leftIcon={<Ionicons name="logo-apple" size={18} color="#FFF" />}
|
||||||
|
onPress={onApple}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TouchableOpacity style={styles.signupRow} onPress={() => nav.navigate('Register')}>
|
<TouchableOpacity style={styles.signupRow} onPress={() => nav.navigate('Register')}>
|
||||||
<Text style={styles.signupText}>
|
<Text style={styles.signupText}>
|
||||||
계정이 없으신가요? <Text style={styles.signupLink}>회원가입</Text>
|
계정이 없으신가요? <Text style={styles.signupLink}>회원가입</Text>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
const KAKAO_JS_KEY = (process.env.EXPO_PUBLIC_KAKAO_JS_KEY as string) || '';
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
async function loadKakaoSDK() {
|
||||||
|
if (Platform.OS !== 'web') return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if ((window as any).Kakao?.isInitialized?.()) return;
|
||||||
|
if (!loaded) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.2/kakao.min.js';
|
||||||
|
s.integrity = 'sha384-TiCUE00h649CAMonG018J2ujOgDKW/kVWlChEuu4jK2vxfAAD0eZxzCKakxg55G4';
|
||||||
|
s.crossOrigin = 'anonymous';
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
const Kakao = (window as any).Kakao;
|
||||||
|
if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kakaoWebLogin(): Promise<string | null> {
|
||||||
|
if (Platform.OS !== 'web') return null;
|
||||||
|
await loadKakaoSDK();
|
||||||
|
const Kakao = (window as any).Kakao;
|
||||||
|
if (!Kakao || !Kakao.isInitialized?.()) {
|
||||||
|
const manual = window.prompt(
|
||||||
|
'카카오 JS 키가 설정되지 않았습니다 (EXPO_PUBLIC_KAKAO_JS_KEY).\n' +
|
||||||
|
'개발 테스트용: 이미 발급된 Kakao access_token을 직접 붙여넣으세요.'
|
||||||
|
);
|
||||||
|
return manual;
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
Kakao.Auth.login({
|
||||||
|
scope: 'profile_nickname,profile_image,account_email',
|
||||||
|
success: (authObj: any) => resolve(authObj.access_token),
|
||||||
|
fail: () => resolve(null),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kakaoNativeLogin(): Promise<string | null> {
|
||||||
|
// @react-native-seoul/kakao-login 은 bare workflow + Android/iOS 네이티브 설정이 필요
|
||||||
|
// 현재 Expo managed 환경에서는 불가 — 배포 빌드 후 동작
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { deviceApi } from '@/api/endpoints';
|
||||||
|
|
||||||
|
export async function registerForPushAsync(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
if (Platform.OS === 'web') return null;
|
||||||
|
const Notifications = await import('expo-notifications');
|
||||||
|
const Device = await import('expo-device');
|
||||||
|
|
||||||
|
if (!Device.isDevice) return null;
|
||||||
|
|
||||||
|
const { status: existing } = await Notifications.getPermissionsAsync();
|
||||||
|
let status = existing;
|
||||||
|
if (status !== 'granted') {
|
||||||
|
const res = await Notifications.requestPermissionsAsync();
|
||||||
|
status = res.status;
|
||||||
|
}
|
||||||
|
if (status !== 'granted') return null;
|
||||||
|
|
||||||
|
const tokenData = await Notifications.getExpoPushTokenAsync();
|
||||||
|
const token = tokenData.data;
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
await Notifications.setNotificationChannelAsync('default', {
|
||||||
|
name: 'default',
|
||||||
|
importance: Notifications.AndroidImportance.DEFAULT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deviceApi.register(token, Platform.OS as any);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export const useAuthStore = create<State>((set) => ({
|
|||||||
try {
|
try {
|
||||||
const me = await authApi.me();
|
const me = await authApi.me();
|
||||||
set({ user: me, loading: false });
|
set({ user: me, loading: false });
|
||||||
|
// push registration (non-blocking)
|
||||||
|
import('@/services/push').then((m) => m.registerForPushAsync()).catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
await clearToken();
|
await clearToken();
|
||||||
set({ user: null, loading: false });
|
set({ user: null, loading: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user