diff --git a/README.ko.md b/README.ko.md index bfd679a..50d8881 100644 --- a/README.ko.md +++ b/README.ko.md @@ -27,6 +27,7 @@ - 주유 기록·영수증 첨부, 오피넷 주변 주유소(선택) - 전기차 충전소 찾기(한국환경공단 API, 선택) — 주유소와 마찬가지로 거리순/가격순 검색, 지도에 번호 마커로 표시 - OBD 수집(Torque Pro), REST/WebSocket 텔레메트리, 자동 트립 분할 +- 현대 블루링크 커넥티드카 연동(베타, 국내 전용) — OBD 동글 없이 실제 주행거리·주행가능거리·경고등 조회, 오도미터 자동 동기화. 가족 구성원 각자 프로필에서 본인 계정 연동 - 주행 리포트, 경로 지도 (OSM / 카카오 / 네이버 / T맵) 및 진행 방향 화살표, 주행 개별 메모 추가/편집 및 역지오코딩 - 대시보드 알림 배지 및 차량 요약 카드 (최근 주유 비용 포함) - 차량별 관리 레벨·뱃지(게이미피케이션) 전용 화면 diff --git a/README.md b/README.md index c70a9d6..9497d4b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Docs: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) · [`docs/INTEGRATIONS.md - Fuel logging with receipt attachments; Opinet nearby stations (optional) - EV charging station finder (K-eco API, optional) — same distance/price search as gas stations, numbered markers on the map - OBD ingest (Torque Pro) and REST/WebSocket telemetry; auto trip segmentation +- Hyundai Bluelink connected-car integration (beta, Korea-only) — real odometer, distance-to-empty, and warning-light status with no OBD dongle, with automatic odometer sync; each family member links their own account under Profile - Trip reports, route maps (OSM / Kakao / Naver / T map) with direction arrows; inline trip notes editing and reverse geocoding - Dashboard reminder badges and vehicle summary cards (including last fuel cost) - Per-vehicle care level & badges (gamification) screen diff --git a/apps/api/prisma/migrations/20260724221111_add_hyundai_connected_car_integration/migration.sql b/apps/api/prisma/migrations/20260724221111_add_hyundai_connected_car_integration/migration.sql new file mode 100644 index 0000000..1f9d874 --- /dev/null +++ b/apps/api/prisma/migrations/20260724221111_add_hyundai_connected_car_integration/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "HyundaiAccountLink" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HyundaiAccountLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HyundaiVehicleLink" ( + "id" TEXT NOT NULL, + "vehicleId" TEXT NOT NULL, + "accountLinkId" TEXT NOT NULL, + "hyundaiCarId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "HyundaiVehicleLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "HyundaiAccountLink_userId_key" ON "HyundaiAccountLink"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "HyundaiVehicleLink_vehicleId_key" ON "HyundaiVehicleLink"("vehicleId"); + +-- AddForeignKey +ALTER TABLE "HyundaiAccountLink" ADD CONSTRAINT "HyundaiAccountLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HyundaiVehicleLink" ADD CONSTRAINT "HyundaiVehicleLink_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "Vehicle"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HyundaiVehicleLink" ADD CONSTRAINT "HyundaiVehicleLink_accountLinkId_fkey" FOREIGN KEY ("accountLinkId") REFERENCES "HyundaiAccountLink"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/apps/api/prisma/migrations/20260724222929_add_hyundai_redirect_uri/migration.sql b/apps/api/prisma/migrations/20260724222929_add_hyundai_redirect_uri/migration.sql new file mode 100644 index 0000000..c30dd3d --- /dev/null +++ b/apps/api/prisma/migrations/20260724222929_add_hyundai_redirect_uri/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "HyundaiAccountLink" ADD COLUMN "redirectUri" TEXT NOT NULL; + diff --git a/apps/api/prisma/migrations/20260724223553_add_hyundai_user_id/migration.sql b/apps/api/prisma/migrations/20260724223553_add_hyundai_user_id/migration.sql new file mode 100644 index 0000000..a5ed6b2 --- /dev/null +++ b/apps/api/prisma/migrations/20260724223553_add_hyundai_user_id/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "HyundaiAccountLink" ADD COLUMN "hyundaiUserId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "HyundaiAccountLink_hyundaiUserId_key" ON "HyundaiAccountLink"("hyundaiUserId"); + diff --git a/apps/api/prisma/migrations/20260724224535_add_hyundai_data_consent/migration.sql b/apps/api/prisma/migrations/20260724224535_add_hyundai_data_consent/migration.sql new file mode 100644 index 0000000..8fde804 --- /dev/null +++ b/apps/api/prisma/migrations/20260724224535_add_hyundai_data_consent/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "HyundaiAccountLink" ADD COLUMN "dataConsentGrantedAt" TIMESTAMP(3); + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d895c5f..dc5cee3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -39,9 +39,10 @@ model User { role UserRole @default(GENERAL) createdAt DateTime @default(now()) - vehicleAccess UserVehicleAccess[] - fuelLogs FuelLog[] - pushSubscriptions PushSubscription[] + vehicleAccess UserVehicleAccess[] + fuelLogs FuelLog[] + pushSubscriptions PushSubscription[] + hyundaiAccountLink HyundaiAccountLink? } model Vehicle { @@ -70,6 +71,7 @@ model Vehicle { attachments Attachment[] xpEvents XpEvent[] badges VehicleBadge[] + hyundaiLink HyundaiVehicleLink? } // 관리를 잘 할수록(정시 정비, 꼼꼼한 기록, 좋은 연비) 쌓이는 경험치 내역. @@ -115,6 +117,42 @@ model UserVehicleAccess { @@id([userId, vehicleId]) } +// 현대 Hyundai Developers 커넥티드카 API 연동 — 사용자 1명당 블루링크 계정 1개를 연결한다. +// 앱 전역 Client ID/Secret은 Setting 테이블(관리자 키)에 두고, 사용자별로 발급받는 +// 액세스/리프레시 토큰은 Setting과 성격이 달라(개인 데이터) 별도 테이블로 둔다. +// 정확한 필드(만료 계산 방식 등)는 Hyundai Developers 콘솔의 API 규격서 확인 후 조정한다. +model HyundaiAccountLink { + id String @id @default(cuid()) + userId String @unique + // 현대 측이 발급한 사용자 고유 식별자(/user/profile의 id) — 데이터 조회 불가 알림 + // 웹훅이 이 값으로 계정 삭제를 통지하므로, 웹훅에서 어느 연동을 지울지 찾는 키로 쓴다. + hyundaiUserId String? @unique + accessToken String + refreshToken String + // 토큰 갱신 요청에도 최초 로그인 때 쓴 redirect_uri가 필요해서 함께 저장해둔다. + redirectUri String + // 개인정보 제3자 제공 동의 완료 시각 — 이게 없으면 데이터 API 전부가 5005로 실패한다. + dataConsentGrantedAt DateTime? + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + vehicles HyundaiVehicleLink[] +} + +// Garage 차량 ↔ 현대 커넥티드카 API의 carId 매핑. 차량 1대는 최대 1개 블루링크 차량에만 연결된다. +model HyundaiVehicleLink { + id String @id @default(cuid()) + vehicleId String @unique + accountLinkId String + hyundaiCarId String + createdAt DateTime @default(now()) + + vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) + accountLink HyundaiAccountLink @relation(fields: [accountLinkId], references: [id], onDelete: Cascade) +} + // OBD/GPS 원시 데이터. 데이터량이 늘면 TimescaleDB 하이퍼테이블로 전환 검토. model TelemetryRaw { id BigInt @id @default(autoincrement()) @@ -138,7 +176,7 @@ model TelemetryRaw { } model Trip { - id String @id @default(cuid()) + id String @id @default(cuid()) vehicleId String startTime DateTime endTime DateTime? @@ -153,18 +191,18 @@ model Trip { } model FuelLog { - id String @id @default(cuid()) - vehicleId String - userId String? - date DateTime - odometer Int - liters Float - cost Int - fullTank Boolean @default(true) - location String? - latitude Float? - longitude Float? - address String? + id String @id @default(cuid()) + vehicleId String + userId String? + date DateTime + odometer Int + liters Float + cost Int + fullTank Boolean @default(true) + location String? + latitude Float? + longitude Float? + address String? opinetStationId String? vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f0683a7..3c44ec8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -18,6 +18,8 @@ import { maintenancePresetRoutes } from "./routes/maintenancePresets.js"; import { backupRoutes } from "./routes/backup.js"; import { opinetRoutes } from "./routes/opinet.js"; import { evChargerRoutes } from "./routes/evCharger.js"; +import { hyundaiRoutes } from "./routes/hyundai.js"; +import { hyundaiWebhookRoutes } from "./routes/hyundaiWebhook.js"; import { settingsRoutes } from "./routes/settings.js"; import { mapProviderRoutes } from "./routes/mapProviders.js"; import { pushRoutes } from "./routes/push.js"; @@ -115,6 +117,8 @@ export async function buildApp(): Promise { await app.register(backupRoutes, { prefix: "/api/backup" }); await app.register(opinetRoutes, { prefix: "/api/opinet" }); await app.register(evChargerRoutes, { prefix: "/api/ev-charger" }); + await app.register(hyundaiRoutes, { prefix: "/api/hyundai" }); + await app.register(hyundaiWebhookRoutes, { prefix: "/api/hyundai/webhook" }); await app.register(settingsRoutes, { prefix: "/api/settings" }); await app.register(mapProviderRoutes, { prefix: "/api/map" }); await app.register(pushRoutes, { prefix: "/api/push" }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 91089e1..078d59f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ import { prisma } from "./lib/prisma.js"; import { startReminderJob } from "./jobs/reminders.js"; import { startTripJob } from "./jobs/trips.js"; import { startTelemetryRetentionJob } from "./jobs/telemetryRetention.js"; +import { startHyundaiSyncJob } from "./jobs/hyundaiSync.js"; import { ensureMaintenancePresets } from "./lib/seedPresets.js"; const app = await buildApp(); @@ -11,6 +12,7 @@ const app = await buildApp(); startReminderJob(); startTripJob(); startTelemetryRetentionJob(); +startHyundaiSyncJob(); // 기존 차량 중 apiToken이 없는 차량에 대해 토큰을 생성해 준다 (하위 호환성). async function backfillVehicleTokens() { diff --git a/apps/api/src/jobs/hyundaiSync.test.ts b/apps/api/src/jobs/hyundaiSync.test.ts new file mode 100644 index 0000000..72777eb --- /dev/null +++ b/apps/api/src/jobs/hyundaiSync.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from "crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "../lib/prisma.js"; + +vi.mock("../lib/hyundai.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchMileage: vi.fn() }; +}); + +const { fetchMileage } = await import("../lib/hyundai.js"); +const { syncHyundaiMileage } = await import("./hyundaiSync.js"); + +// 블루링크 오도미터 동기화 잡 — OBD 웹훅과 동일한 "기존 값보다 클 때만 갱신" 규칙을 +// 지키는지, 그리고 링크된 차량 전체를 순회하는지 검증한다. +describe("syncHyundaiMileage", () => { + let vehicleId: string; + let userId: string; + + beforeEach(async () => { + const suffix = randomUUID(); + const user = await prisma.user.create({ + data: { name: "Test User", email: `test-hsync-${suffix}@example.com`, passwordHash: "x", role: "GENERAL" }, + }); + userId = user.id; + + const vehicle = await prisma.vehicle.create({ + data: { name: `Test Vehicle ${suffix}`, apiToken: randomUUID(), odometer: 1000 }, + }); + vehicleId = vehicle.id; + + const accountLink = await prisma.hyundaiAccountLink.create({ + data: { + userId, + accessToken: "at", + refreshToken: "rt", + redirectUri: "https://example.com/callback", + expiresAt: new Date(Date.now() + 3600_000), + }, + }); + + await prisma.hyundaiVehicleLink.create({ + data: { vehicleId, accountLinkId: accountLink.id, hyundaiCarId: `car-${suffix}` }, + }); + + vi.mocked(fetchMileage).mockReset(); + }); + + afterEach(async () => { + await prisma.vehicle.delete({ where: { id: vehicleId } }).catch(() => {}); + await prisma.user.delete({ where: { id: userId } }).catch(() => {}); + }); + + it("bumps the vehicle odometer when the fetched value is higher", async () => { + vi.mocked(fetchMileage).mockResolvedValue({ odometerKm: 1500, distanceToEmptyKm: 300 }); + + await syncHyundaiMileage(); + + const vehicle = await prisma.vehicle.findUniqueOrThrow({ where: { id: vehicleId } }); + expect(vehicle.odometer).toBe(1500); + }); + + it("does not overwrite the odometer when the fetched value is lower (manual entry stays authoritative)", async () => { + vi.mocked(fetchMileage).mockResolvedValue({ odometerKm: 500, distanceToEmptyKm: 300 }); + + await syncHyundaiMileage(); + + const vehicle = await prisma.vehicle.findUniqueOrThrow({ where: { id: vehicleId } }); + expect(vehicle.odometer).toBe(1000); + }); + + it("leaves the odometer untouched when the data API call fails", async () => { + vi.mocked(fetchMileage).mockResolvedValue(null); + + await syncHyundaiMileage(); + + const vehicle = await prisma.vehicle.findUniqueOrThrow({ where: { id: vehicleId } }); + expect(vehicle.odometer).toBe(1000); + }); +}); diff --git a/apps/api/src/jobs/hyundaiSync.ts b/apps/api/src/jobs/hyundaiSync.ts new file mode 100644 index 0000000..e8fb7b9 --- /dev/null +++ b/apps/api/src/jobs/hyundaiSync.ts @@ -0,0 +1,37 @@ +import cron from "node-cron"; +import { prisma } from "../lib/prisma.js"; +import { fetchMileage } from "../lib/hyundai.js"; +import { getValidAccessTokenForVehicleLink } from "../lib/hyundaiToken.js"; + +// 블루링크 오도미터는 "시동 종료 시점" 기준으로만 갱신되므로(규격서 확인), 하루에 +// 몇 번씩 폴링해봐야 의미가 없다 — reminders 잡과 같은 하루 2회 패턴을 그대로 쓴다. +// OBD 웹훅의 bumpOdometerIfHigher와 동일한 규칙(기존 값보다 클 때만 갱신)을 적용해, +// 수동 기록이 더 최신이면 덮어쓰지 않는다. +export async function syncHyundaiMileage(): Promise { + const links = await prisma.hyundaiVehicleLink.findMany(); + + for (const link of links) { + try { + const accessToken = await getValidAccessTokenForVehicleLink(link.accountLinkId); + if (!accessToken) continue; + + const mileage = await fetchMileage(accessToken, link.hyundaiCarId); + if (!mileage || mileage.odometerKm <= 0) continue; + + const odometer = Math.round(mileage.odometerKm); + const vehicle = await prisma.vehicle.findUnique({ where: { id: link.vehicleId }, select: { odometer: true } }); + if (vehicle && odometer > vehicle.odometer) { + await prisma.vehicle.update({ where: { id: link.vehicleId }, data: { odometer } }); + } + } catch (err) { + console.error(`[hyundai-sync] failed for vehicle ${link.vehicleId}`, err); + } + } +} + +export function startHyundaiSyncJob(): void { + syncHyundaiMileage().catch((err) => console.error("[hyundai-sync] initial sync failed", err)); + cron.schedule("0 7,19 * * *", () => { + syncHyundaiMileage().catch((err) => console.error("[hyundai-sync] scheduled sync failed", err)); + }); +} diff --git a/apps/api/src/lib/hyundai.test.ts b/apps/api/src/lib/hyundai.test.ts new file mode 100644 index 0000000..edb0825 --- /dev/null +++ b/apps/api/src/lib/hyundai.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./settings.js", () => ({ + getSetting: vi.fn(async (key: string) => { + if (key === "HYUNDAI_CLIENT_ID") return "test-client-id"; + if (key === "HYUNDAI_CLIENT_SECRET") return "test-client-secret"; + return null; + }), +})); + +import { + getAuthorizeUrl, + exchangeCodeForToken, + refreshAccessToken, + revokeAccessToken, + fetchUserProfile, + getDataConsentUrl, + rejectDataConsent, + fetchLinkedVehicles, + fetchContract, + fetchMileage, + fetchVehicleStatus, + fetchEvBattery, + fetchEvCharging, +} from "./hyundai.js"; + +type FormRequestInit = { method: string; headers: Record; body: string }; + +// 콘솔 API 가이드로 확인된 실제 엔드포인트·요청 형식과 일치하는지 검증한다. +// (요청 구성만 검증 — 실제 Hyundai 서버로 나가는 호출은 fetch를 스텁해서 막는다.) +describe("hyundai account API request construction", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("builds the authorize URL with the confirmed path and params", async () => { + const url = await getAuthorizeUrl("https://example.com/callback", "state123"); + expect(url).toBe( + "https://prd.kr-ccapi.hyundai.com/api/v1/user/oauth2/authorize?response_type=code&client_id=test-client-id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=state123", + ); + }); + + it("exchanges an authorization code with Basic auth and form-encoded body", async () => { + const fetchMock = vi.fn(async (_url: string, _options: FormRequestInit) => ({ + ok: true, + json: async () => ({ access_token: "at-1", refresh_token: "rt-1", expires_in: 3600 }), + })); + vi.stubGlobal("fetch", fetchMock); + + const token = await exchangeCodeForToken("auth-code-1", "https://example.com/callback"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://prd.kr-ccapi.hyundai.com/api/v1/user/oauth2/token"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(options.headers["Authorization"]).toBe( + `Basic ${Buffer.from("test-client-id:test-client-secret").toString("base64")}`, + ); + expect(options.body).toBe("grant_type=authorization_code&code=auth-code-1&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"); + + expect(token).not.toBeNull(); + expect(token?.accessToken).toBe("at-1"); + expect(token?.refreshToken).toBe("rt-1"); + expect(token?.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it("refreshes a token using grant_type=refresh_token", async () => { + const fetchMock = vi.fn(async (_url: string, _options: FormRequestInit) => ({ + ok: true, + json: async () => ({ access_token: "at-2", refresh_token: "rt-2", expires_in: 3600 }), + })); + vi.stubGlobal("fetch", fetchMock); + + await refreshAccessToken("old-refresh-token", "https://example.com/callback"); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.body).toBe( + "grant_type=refresh_token&refresh_token=old-refresh-token&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback", + ); + }); + + it("revokes a token using grant_type=delete", async () => { + const fetchMock = vi.fn(async (_url: string, _options: FormRequestInit) => ({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + const result = await revokeAccessToken("access-token-1"); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.body).toBe("grant_type=delete&access_token=access-token-1"); + expect(result).toBe(true); + }); + + it("returns null when the token endpoint responds with a non-OK status", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 400 }))); + const token = await exchangeCodeForToken("bad-code", "https://example.com/callback"); + expect(token).toBeNull(); + }); +}); + +describe("hyundai account API — user profile and data consent", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("parses the user profile id field (not userId)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ id: "hyundai-user-1", email: "test@ccsp.com", name: "tester" }), + })), + ); + const profile = await fetchUserProfile("access-token-1"); + expect(profile).toEqual({ id: "hyundai-user-1", email: "test@ccsp.com", name: "tester" }); + }); + + it("builds the data consent URL with token and state", async () => { + const url = await getDataConsentUrl("access-token-1", "state123"); + expect(url).toBe( + "https://dev.kr-ccapi.hyundai.com/api/v1/car-service/terms/agreement?token=Bearer+access-token-1&state=state123", + ); + }); + + it("rejects data consent via the reject endpoint", async () => { + const fetchMock = vi.fn(async (url: string) => ({ ok: url.endsWith("/terms/reject") })); + vi.stubGlobal("fetch", fetchMock); + const result = await rejectDataConsent("access-token-1"); + expect(result).toBe(true); + }); +}); + +// 응답 형식은 규격서(Sample Test 페이지)로 확인된 필드 그대로 검증한다. +describe("hyundai data API parsing", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("parses the vehicle list from a carlist response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + cars: [ + { carId: "car-1", carNickname: "내 차", carType: "GN", carName: "그랜저", carSellname: "The Next Grandeur" }, + { carId: "car-2" }, + ], + }), + })), + ); + + const vehicles = await fetchLinkedVehicles("access-token-1"); + expect(vehicles).toEqual([ + { carId: "car-1", nickname: "내 차", model: "그랜저" }, + { carId: "car-2", nickname: null, model: null }, + ]); + }); + + it("drops cars with no carId instead of throwing", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ cars: [{ carNickname: "no id" }] }) }))); + const vehicles = await fetchLinkedVehicles("access-token-1"); + expect(vehicles).toEqual([]); + }); + + it("fetches dte and odometer in parallel and converts units to km", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith("/dte")) { + return { ok: true, json: async () => ({ value: 42, unit: 1 }) }; // unit 1 = already km + } + if (url.endsWith("/odometer")) { + return { ok: true, json: async () => ({ odometers: [{ value: 12345000, unit: 2 }] }) }; // unit 2 = meters + } + throw new Error(`unexpected url ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const mileage = await fetchMileage("access-token-1", "car-1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mileage).toEqual({ odometerKm: 12345, distanceToEmptyKm: 42 }); + }); + + it("returns null mileage when both endpoints fail", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500 }))); + const mileage = await fetchMileage("access-token-1", "car-1"); + expect(mileage).toBeNull(); + }); + + it("collects only the warning keys whose endpoint reports status true", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => ({ + ok: true, + json: async () => ({ status: url.endsWith("/lowFuel") || url.endsWith("/breakOil") }), + }))); + + const status = await fetchVehicleStatus("access-token-1", "car-1"); + expect(status?.warnings.sort()).toEqual(["brakeFluid", "lowFuel"]); + expect(status?.lastParkedLat).toBeNull(); + }); + + it("parses the connected-service contract dates", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ subscribeDate: "20180711", endDate: "20230710" }) })), + ); + const contract = await fetchContract("access-token-1", "car-1"); + expect(contract).toEqual({ subscribeDate: "20180711", endDate: "20230710" }); + }); + + it("parses EV battery state of charge", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ soc: 100 }) }))); + const battery = await fetchEvBattery("access-token-1", "car-1"); + expect(battery).toEqual({ socPercent: 100 }); + }); + + it("parses EV charging status including target SOC", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + batteryPlugin: 1, + batteryCharge: true, + soc: 51, + targetSOC: { plugType: 0, targetSOClevel: 80 }, + }), + })), + ); + const charging = await fetchEvCharging("access-token-1", "car-1"); + expect(charging).toEqual({ isCharging: true, cableConnected: true, socPercent: 51, targetSocPercent: 80 }); + }); + + it("treats batteryPlugin 0 as cable not connected", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ batteryPlugin: 0, batteryCharge: false, soc: 80 }) })), + ); + const charging = await fetchEvCharging("access-token-1", "car-1"); + expect(charging).toEqual({ isCharging: false, cableConnected: false, socPercent: 80, targetSocPercent: null }); + }); +}); diff --git a/apps/api/src/lib/hyundai.ts b/apps/api/src/lib/hyundai.ts new file mode 100644 index 0000000..d4e6203 --- /dev/null +++ b/apps/api/src/lib/hyundai.ts @@ -0,0 +1,320 @@ +import type { + HyundaiVehicleSummary, + HyundaiMileage, + HyundaiVehicleStatus, + HyundaiDrivingHabit, +} from "@garage/shared"; +import { getSetting } from "./settings.js"; + +// Hyundai Developers(developers.hyundai.com) 커넥티드카 API 클라이언트. +// +// 아래는 전부 실제 콘솔 API 규격서(로그인 후 볼 수 있는 정식 스펙 문서)로 확인된 +// 엔드포인트다. 확인 안 된 건 각 함수 주석에 TODO로 명시했다 — 최종 주차 위치, +// 운전습관(90일 안전운전점수), 차량상태 경고등 7종의 정확한 경로. +const ACCOUNT_BASE_URL = "https://prd.kr-ccapi.hyundai.com"; +// 계정 API(로그인/토큰/프로필)는 prd., 차량 데이터 API 전부(동의/차량목록/상태 등)는 +// dev. — 규격서 문서 전체가 데이터 API를 예외 없이 dev.로 표기하고 있어 환경 구분이 +// 아니라 고정된 host임이 확인됐다. +const DATA_BASE_URL = "https://dev.kr-ccapi.hyundai.com"; + +export type HyundaiTokenResponse = { + accessToken: string; + refreshToken: string; + expiresAt: Date; +}; + +async function getClientCredentials(): Promise<{ clientId: string; clientSecret: string } | null> { + const [clientId, clientSecret] = await Promise.all([ + getSetting("HYUNDAI_CLIENT_ID"), + getSetting("HYUNDAI_CLIENT_SECRET"), + ]); + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +function basicAuthHeader(clientId: string, clientSecret: string): string { + return `Basic ${Buffer.from(`${clientId}:${clientSecret}`, "utf8").toString("base64")}`; +} + +// 규격서로 확인된 공통 에러 응답 형식({errCode, errMsg, errId}) — 모든 엔드포인트가 +// 4xx에서 이 형식을 쓴다. HTTP status만 찍는 것보다 원인 파악에 훨씬 유용하다. +async function describeError(res: Response): Promise { + try { + const body = (await res.clone().json()) as { errCode?: string; errMsg?: string; errId?: string }; + if (body.errCode) return `${res.status} ${body.errCode} ${body.errMsg ?? ""}`.trim(); + } catch { + /* 응답이 JSON이 아니면 status만 남긴다 */ + } + return String(res.status); +} + +export async function isHyundaiConfigured(): Promise { + return (await getClientCredentials()) !== null; +} + +// 1) 로그인 인증 요청 — 사용자를 이 URL로 리다이렉트하면 현대 통합계정 로그인 + +// 차량별 접근 권한 동의 후 redirectUri로 ?code=&state= 와 함께 돌아온다. +export async function getAuthorizeUrl(redirectUri: string, state: string): Promise { + const creds = await getClientCredentials(); + if (!creds) return null; + + const params = new URLSearchParams({ + response_type: "code", + client_id: creds.clientId, + redirect_uri: redirectUri, + state, + }); + return `${ACCOUNT_BASE_URL}/api/v1/user/oauth2/authorize?${params.toString()}`; +} + +// 토큰 발급/갱신/삭제는 grant_type만 다른 같은 엔드포인트를 쓴다. +async function callTokenEndpoint( + creds: { clientId: string; clientSecret: string }, + form: Record, +): Promise { + try { + const res = await fetch(`${ACCOUNT_BASE_URL}/api/v1/user/oauth2/token`, { + method: "POST", + headers: { + Authorization: basicAuthHeader(creds.clientId, creds.clientSecret), + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(form).toString(), + }); + if (!res.ok) { + console.error(`[hyundai] token endpoint failed: ${await describeError(res)}`); + return null; + } + + const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number }; + if (!data.access_token || !data.refresh_token || !data.expires_in) { + console.error("[hyundai] token endpoint response missing expected fields", data); + return null; + } + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000), + }; + } catch (err) { + console.error("[hyundai] token endpoint call failed", err); + return null; + } +} + +// 2) 사용자 토큰 발급 — 인가 코드를 액세스/리프레시 토큰으로 교환한다. +export async function exchangeCodeForToken( + code: string, + redirectUri: string, +): Promise { + const creds = await getClientCredentials(); + if (!creds) return null; + return callTokenEndpoint(creds, { grant_type: "authorization_code", code, redirect_uri: redirectUri }); +} + +// 사용자 토큰 갱신. 규격서의 redirect_uri 파라미터는 전체 요청 공통 OPTIONAL로만 +// 표시돼 있어 갱신에도 필수인지는 명시적이지 않지만, 포함해도 무해하다고 보고 유지. +export async function refreshAccessToken(refreshToken: string, redirectUri: string): Promise { + const creds = await getClientCredentials(); + if (!creds) return null; + return callTokenEndpoint(creds, { + grant_type: "refresh_token", + refresh_token: refreshToken, + redirect_uri: redirectUri, + }); +} + +// 사용자 토큰 삭제(연동 해제 시 호출) — 성공 여부만 반환한다. +export async function revokeAccessToken(accessToken: string): Promise { + const creds = await getClientCredentials(); + if (!creds) return false; + + try { + const res = await fetch(`${ACCOUNT_BASE_URL}/api/v1/user/oauth2/token`, { + method: "POST", + headers: { + Authorization: basicAuthHeader(creds.clientId, creds.clientSecret), + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ grant_type: "delete", access_token: accessToken }).toString(), + }); + if (!res.ok) console.error(`[hyundai] token revoke failed: ${await describeError(res)}`); + return res.ok; + } catch (err) { + console.error("[hyundai] token revoke call failed", err); + return false; + } +} + +export type HyundaiUserProfile = { id: string; email: string | null; name: string | null }; + +// 사용자 정보 조회 — 응답 필드는 id(사용자 고유 식별자)/email/name/mobileNum/birthdate/lang/social. +export async function fetchUserProfile(accessToken: string): Promise { + try { + const res = await fetch(`${ACCOUNT_BASE_URL}/api/v1/user/profile`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + console.error(`[hyundai] user profile failed: ${await describeError(res)}`); + return null; + } + const data = (await res.json()) as { id?: string; email?: string; name?: string }; + return data.id ? { id: data.id, email: data.email ?? null, name: data.name ?? null } : null; + } catch (err) { + console.error("[hyundai] user profile call failed", err); + return null; + } +} + +// 개인정보 제3자 제공 동의 — 계정 로그인(토큰 발급)과 별개로, 이 동의가 없으면 +// 데이터 API 전부가 5005(No Agreement Error)로 실패한다. 응답이 302 리다이렉트라 +// 로그인 URL과 같은 패턴(브라우저를 이 URL로 보낸 뒤 redirectUri로 ?userId=&state= +// 받는다) — 다만 문서상 method는 POST + x-www-form-urlencoded로 명시돼 있어 +// 실제로는 자동제출 폼으로 열어야 할 수 있다(단순 링크 이동이 아닐 수 있음). +export async function getDataConsentUrl(accessToken: string, state: string): Promise { + const params = new URLSearchParams({ token: `Bearer ${accessToken}`, state }); + return `${DATA_BASE_URL}/api/v1/car-service/terms/agreement?${params.toString()}`; +} + +// 개인정보 제공 철회 통지 — 연동 해제 시 revokeAccessToken과 함께 호출해 동의 상태도 정리한다. +export async function rejectDataConsent(accessToken: string): Promise { + try { + const res = await fetch(`${DATA_BASE_URL}/api/v1/car-service/terms/reject`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) console.error(`[hyundai] data consent reject failed: ${await describeError(res)}`); + return res.ok; + } catch (err) { + console.error("[hyundai] data consent reject call failed", err); + return false; + } +} + +async function authedGet(path: string, accessToken: string): Promise | null> { + try { + const res = await fetch(`${DATA_BASE_URL}${path}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + console.error(`[hyundai] ${path} failed: ${await describeError(res)}`); + return null; + } + return (await res.json()) as Record; + } catch (err) { + console.error(`[hyundai] ${path} call failed`, err); + return null; + } +} + +// 내 차량 리스트 조회 +export async function fetchLinkedVehicles(accessToken: string): Promise { + const data = await authedGet("/api/v1/car/profile/carlist", accessToken); + const cars = data?.cars; + if (!Array.isArray(cars)) return []; + + return cars + .filter((car): car is Record => typeof car === "object" && car !== null && !!car.carId) + .map((car) => ({ + carId: String(car.carId), + nickname: car.carNickname ? String(car.carNickname) : null, + model: car.carName ? String(car.carName) : car.carSellname ? String(car.carSellname) : null, + })); +} + +// 커넥티드 서비스 가입일/무료 서비스 종료일 — subscribeDate/endDate는 YYYYMMDD 문자열. +export type HyundaiContract = { subscribeDate: string; endDate: string | null }; +export async function fetchContract(accessToken: string, carId: string): Promise { + const data = await authedGet(`/api/v1/car/profile/${carId}/contract`, accessToken); + if (!data?.subscribeDate) return null; + return { subscribeDate: String(data.subscribeDate), endDate: data.endDate ? String(data.endDate) : null }; +} + +// 거리 값 단위 코드 환산 — 0:feet, 1:km, 2:meter, 3:miles (규격서로 확인됨). +const DISTANCE_TO_KM: Record = { 0: 0.0003048, 1: 1, 2: 0.001, 3: 1.609344 }; + +function distanceToKm(value: unknown, unit: unknown): number | null { + const num = Number(value); + const unitCode = Number(unit); + if (!Number.isFinite(num) || !(unitCode in DISTANCE_TO_KM)) return null; + return num * DISTANCE_TO_KM[unitCode]; +} + +// 주행거리 API — dte(주행가능거리)와 odometer(누적주행거리)는 서로 다른 엔드포인트이며 +// 응답 형식({value, unit} / {odometers:[{value, unit}]})까지 규격서로 확인됨. +export async function fetchMileage(accessToken: string, carId: string): Promise { + const [dte, odometer] = await Promise.all([ + authedGet(`/api/v1/car/status/${carId}/dte`, accessToken), + authedGet(`/api/v1/car/status/${carId}/odometer`, accessToken), + ]); + + const distanceToEmptyKm = dte ? distanceToKm(dte.value, dte.unit) : null; + + const odometers = odometer?.odometers; + const odometerRow = Array.isArray(odometers) ? (odometers[0] as Record | undefined) : undefined; + const odometerKm = odometerRow ? distanceToKm(odometerRow.value, odometerRow.unit) : null; + + if (distanceToEmptyKm === null && odometerKm === null) return null; + return { odometerKm: odometerKm ?? 0, distanceToEmptyKm }; +} + +export type HyundaiEvBattery = { socPercent: number }; +export async function fetchEvBattery(accessToken: string, carId: string): Promise { + const data = await authedGet(`/api/v1/car/status/${carId}/ev/battery`, accessToken); + return typeof data?.soc === "number" ? { socPercent: data.soc } : null; +} + +export type HyundaiEvCharging = { + isCharging: boolean; + cableConnected: boolean; + socPercent: number; + targetSocPercent: number | null; +}; +export async function fetchEvCharging(accessToken: string, carId: string): Promise { + const data = await authedGet(`/api/v1/car/status/${carId}/ev/charging`, accessToken); + if (!data || typeof data.batteryPlugin !== "number" || typeof data.soc !== "number") return null; + const targetSoc = data.targetSOC as Record | undefined; + return { + isCharging: Boolean(data.batteryCharge), + cableConnected: data.batteryPlugin > 0, + socPercent: data.soc, + targetSocPercent: typeof targetSoc?.targetSOClevel === "number" ? targetSoc.targetSOClevel : null, + }; +} + +// 차량상태 — 경고등 7종 엔드포인트/응답 형식 모두 규격서로 확인됨. +// TODO: 최종 주차 위치 엔드포인트는 규격서에 아직 없었다 — 확인되기 전까지 null 고정. +const WARNING_PATHS: Record = { + lowFuel: "lowFuel", + tirePressure: "tirePressure", + lampWire: "lampWire", + smartKeyBattery: "smartKeyBattery", + washerFluid: "washerFluid", + brakeFluid: "breakOil", // API 자체의 표기(오타로 보이나 규격서 원문이 이렇다) + engineOil: "engineOil", +}; + +export async function fetchVehicleStatus( + accessToken: string, + carId: string, +): Promise { + const entries = Object.entries(WARNING_PATHS); + const results = await Promise.all( + entries.map(([, path]) => authedGet(`/api/v1/car/status/warning/${carId}/${path}`, accessToken)), + ); + + const warnings = entries + .map(([key], i) => (results[i]?.status === true ? key : null)) + .filter((key): key is string => key !== null); + + return { lastParkedLat: null, lastParkedLon: null, warnings }; +} + +// TODO: 운전습관(90일 안전운전점수) 엔드포인트 미확인. +export async function fetchDrivingHabit( + _accessToken: string, + _carId: string, +): Promise { + console.warn("[hyundai] fetchDrivingHabit: endpoint not yet confirmed"); + return null; +} diff --git a/apps/api/src/lib/hyundaiToken.ts b/apps/api/src/lib/hyundaiToken.ts new file mode 100644 index 0000000..8d7a282 --- /dev/null +++ b/apps/api/src/lib/hyundaiToken.ts @@ -0,0 +1,48 @@ +import { prisma } from "./prisma.js"; +import { refreshAccessToken } from "./hyundai.js"; + +type HyundaiAccountLinkRow = { + id: string; + userId: string; + accessToken: string; + refreshToken: string; + redirectUri: string; + expiresAt: Date; +}; + +// 액세스 토큰이 곧 만료되거나 이미 만료됐으면 리프레시 토큰으로 갱신하고 DB에 반영한다. +// 라우트(요청 처리)와 배치 잡(주기 동기화) 양쪽에서 재사용하는 공용 로직이라 여기 둔다. +// 정확한 만료 임박 기준(여유 시간)은 실제 토큰 수명 확인 후 조정. +export async function getValidAccessTokenFor(accountLink: HyundaiAccountLinkRow): Promise { + if (accountLink.expiresAt.getTime() > Date.now() + 60_000) { + return accountLink.accessToken; + } + + const refreshed = await refreshAccessToken(accountLink.refreshToken, accountLink.redirectUri); + if (!refreshed) return null; + + await prisma.hyundaiAccountLink.update({ + where: { id: accountLink.id }, + data: { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + }, + }); + return refreshed.accessToken; +} + +// 로그인한 사용자 본인 계정의 토큰(연동 시작 직후 등 아직 차량에 안 묶인 경우) +export async function getValidAccessTokenForUser(userId: string): Promise { + const link = await prisma.hyundaiAccountLink.findUnique({ where: { userId } }); + if (!link) return null; + return getValidAccessTokenFor(link); +} + +// 차량에 연동된 accountLinkId 기준으로 토큰을 가져온다 — 조회 요청자가 아니라 +// "그 차량을 연동한 사람"의 계정이 기준이다. +export async function getValidAccessTokenForVehicleLink(accountLinkId: string): Promise { + const accountLink = await prisma.hyundaiAccountLink.findUnique({ where: { id: accountLinkId } }); + if (!accountLink) return null; + return getValidAccessTokenFor(accountLink); +} diff --git a/apps/api/src/routes/hyundai.ts b/apps/api/src/routes/hyundai.ts new file mode 100644 index 0000000..c585f9d --- /dev/null +++ b/apps/api/src/routes/hyundai.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; +import { canAccessVehicle } from "../lib/access.js"; +import { + isHyundaiConfigured, + getAuthorizeUrl, + exchangeCodeForToken, + revokeAccessToken, + fetchUserProfile, + getDataConsentUrl, + rejectDataConsent, + fetchLinkedVehicles, + fetchMileage, + fetchVehicleStatus, + fetchDrivingHabit, +} from "../lib/hyundai.js"; +import { + getValidAccessTokenFor, + getValidAccessTokenForUser, + getValidAccessTokenForVehicleLink, +} from "../lib/hyundaiToken.js"; + +export async function hyundaiRoutes(app: FastifyInstance) { + app.addHook("preHandler", app.authenticate); + + // 관리자가 /integrations에서 Client ID/Secret을 설정했는지 여부 + app.get("/configured", async () => { + return { configured: await isHyundaiConfigured() }; + }); + + // 로그인한 사용자 본인의 블루링크 계정 연동 여부 + 개인정보 제공 동의 여부 + app.get("/account", async (request) => { + const link = await prisma.hyundaiAccountLink.findUnique({ where: { userId: request.user.sub } }); + return { linked: link !== null, consentGranted: link?.dataConsentGrantedAt !== null && link?.dataConsentGrantedAt !== undefined }; + }); + + // 계정 연동 시작 — 프론트가 이 URL을 새 창/리다이렉트로 열면 현대 로그인 후 + // redirectUri(프론트의 콜백 페이지)로 code와 함께 돌아온다. + app.get("/authorize-url", async (request, reply) => { + const { redirectUri } = request.query as { redirectUri?: string }; + if (!redirectUri) return reply.code(400).send({ error: "redirectUri is required" }); + + // TODO: state를 서버 세션/캐시에 저장해두고 콜백에서 검증(CSRF 방지) — 지금은 구조만. + const state = request.user.sub; + const url = await getAuthorizeUrl(redirectUri, state); + if (!url) return reply.code(409).send({ error: "hyundai integration not configured" }); + return { url }; + }); + + // 콜백 페이지(프론트)가 인가 코드를 받은 뒤 이 엔드포인트로 넘겨 토큰 교환 + 계정 연동 저장 + app.post("/link", async (request, reply) => { + const { code, redirectUri } = request.body as { code?: string; redirectUri?: string }; + if (!code || !redirectUri) return reply.code(400).send({ error: "code and redirectUri are required" }); + + const token = await exchangeCodeForToken(code, redirectUri); + if (!token) return reply.code(502).send({ error: "hyundai token exchange failed" }); + + // 웹훅(데이터 조회 불가 알림)이 계정 삭제를 이 id로 통지하므로 함께 저장해둔다. + const profile = await fetchUserProfile(token.accessToken); + + await prisma.hyundaiAccountLink.upsert({ + where: { userId: request.user.sub }, + update: { + hyundaiUserId: profile?.id, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + redirectUri, + expiresAt: token.expiresAt, + }, + create: { + userId: request.user.sub, + hyundaiUserId: profile?.id, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + redirectUri, + expiresAt: token.expiresAt, + }, + }); + return { linked: true }; + }); + + // 개인정보 제3자 제공 동의 시작 — 로그인(토큰 발급)과 별개의 필수 단계. + // 이게 없으면 /vehicles/:vehicleId/mileage 등 데이터 API가 전부 실패한다. + app.get("/data-consent-url", async (request, reply) => { + const link = await prisma.hyundaiAccountLink.findUnique({ where: { userId: request.user.sub } }); + if (!link) return reply.code(409).send({ error: "hyundai account not linked" }); + + const accessToken = await getValidAccessTokenFor(link); + if (!accessToken) return reply.code(409).send({ error: "hyundai account not linked" }); + + const url = await getDataConsentUrl(accessToken, request.user.sub); + return { url }; + }); + + // 동의 콜백 페이지(프론트)가 리다이렉트로 받은 userId/state를 넘기면 동의 완료로 기록한다. + app.post("/consent/complete", async (request, reply) => { + const { state } = request.body as { userId?: string; state?: string }; + if (state !== request.user.sub) return reply.code(400).send({ error: "state mismatch" }); + + const link = await prisma.hyundaiAccountLink.findUnique({ where: { userId: request.user.sub } }); + if (!link) return reply.code(409).send({ error: "hyundai account not linked" }); + + await prisma.hyundaiAccountLink.update({ + where: { userId: request.user.sub }, + data: { dataConsentGrantedAt: new Date() }, + }); + return { consentGranted: true }; + }); + + app.delete("/account", async (request) => { + const link = await prisma.hyundaiAccountLink.findUnique({ where: { userId: request.user.sub } }); + if (link) { + await rejectDataConsent(link.accessToken); + await revokeAccessToken(link.accessToken); + await prisma.hyundaiAccountLink.deleteMany({ where: { userId: request.user.sub } }); + } + return { linked: false }; + }); + + // 연동된 블루링크 계정에 묶인 차량 목록 — Garage 차량과 매칭할 때 선택지로 쓴다. + app.get("/vehicles", async (request, reply) => { + const accessToken = await getValidAccessTokenForUser(request.user.sub); + if (!accessToken) return reply.code(409).send({ error: "hyundai account not linked" }); + return fetchLinkedVehicles(accessToken); + }); + + // 이 Garage 차량이 어느 블루링크 carId에 연결돼 있는지 — 매칭 화면에서 현재 상태 표시용 + app.get("/vehicles/:vehicleId/link", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + const link = await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } }); + return { hyundaiCarId: link?.hyundaiCarId ?? null }; + }); + + // Garage 차량 ↔ 블루링크 carId 연결 + app.put("/vehicles/:vehicleId/link", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { carId } = request.body as { carId?: string }; + if (!carId) return reply.code(400).send({ error: "carId is required" }); + + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + const accountLink = await prisma.hyundaiAccountLink.findUnique({ where: { userId: sub } }); + if (!accountLink) return reply.code(409).send({ error: "hyundai account not linked" }); + + const link = await prisma.hyundaiVehicleLink.upsert({ + where: { vehicleId }, + update: { hyundaiCarId: carId, accountLinkId: accountLink.id }, + create: { vehicleId, hyundaiCarId: carId, accountLinkId: accountLink.id }, + }); + return link; + }); + + app.delete("/vehicles/:vehicleId/link", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + await prisma.hyundaiVehicleLink.deleteMany({ where: { vehicleId } }); + return reply.code(204).send(); + }); + + // 연결된 차량의 데이터 조회 3종 — 각각 별도 API라 개별 엔드포인트로 노출한다. + app.get("/vehicles/:vehicleId/mileage", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + const link = await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } }); + if (!link) return reply.code(404).send({ error: "vehicle not linked to hyundai" }); + + const accessToken = await getValidAccessTokenForVehicleLink(link.accountLinkId); + if (!accessToken) return reply.code(409).send({ error: "hyundai account not linked" }); + + const mileage = await fetchMileage(accessToken, link.hyundaiCarId); + if (!mileage) return reply.code(502).send({ error: "hyundai data api not available yet" }); + return mileage; + }); + + app.get("/vehicles/:vehicleId/status", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + const link = await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } }); + if (!link) return reply.code(404).send({ error: "vehicle not linked to hyundai" }); + + const accessToken = await getValidAccessTokenForVehicleLink(link.accountLinkId); + if (!accessToken) return reply.code(409).send({ error: "hyundai account not linked" }); + + const status = await fetchVehicleStatus(accessToken, link.hyundaiCarId); + if (!status) return reply.code(502).send({ error: "hyundai data api not available yet" }); + return status; + }); + + app.get("/vehicles/:vehicleId/driving-habit", async (request, reply) => { + const { vehicleId } = request.params as { vehicleId: string }; + const { sub, role } = request.user; + if (!(await canAccessVehicle(sub, role, vehicleId))) return reply.code(403).send({ error: "forbidden" }); + + const link = await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } }); + if (!link) return reply.code(404).send({ error: "vehicle not linked to hyundai" }); + + const accessToken = await getValidAccessTokenForVehicleLink(link.accountLinkId); + if (!accessToken) return reply.code(409).send({ error: "hyundai account not linked" }); + + const habit = await fetchDrivingHabit(accessToken, link.hyundaiCarId); + if (!habit) return reply.code(502).send({ error: "hyundai data api not available yet" }); + return habit; + }); +} diff --git a/apps/api/src/routes/hyundaiWebhook.test.ts b/apps/api/src/routes/hyundaiWebhook.test.ts new file mode 100644 index 0000000..26bb745 --- /dev/null +++ b/apps/api/src/routes/hyundaiWebhook.test.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "crypto"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../app.js"; +import { prisma } from "../lib/prisma.js"; + +// 현대 측이 계정 삭제/차량 삭제/동의 철회를 통지하면 개인정보보호법상 즉시 데이터를 +// 지워야 한다 — 이 웹훅이 그 요구를 실제로 지키는지 검증한다. 인증 헤더 없이도 +// 호출 가능해야 하므로(규격서에 인증 방식이 없음) JWT 없이 호출한다. +describe("hyundai data-unavailable webhook", () => { + let app: FastifyInstance; + let userId: string; + let vehicleId: string; + + beforeAll(async () => { + app = await buildApp(); + }); + + beforeEach(async () => { + const suffix = randomUUID(); + + const user = await prisma.user.create({ + data: { name: "Test User", email: `test-hyundai-${suffix}@example.com`, passwordHash: "x", role: "GENERAL" }, + }); + userId = user.id; + + const vehicle = await prisma.vehicle.create({ data: { name: `Test Vehicle ${suffix}`, apiToken: randomUUID() } }); + vehicleId = vehicle.id; + + const accountLink = await prisma.hyundaiAccountLink.create({ + data: { + userId, + hyundaiUserId: `hyundai-user-${suffix}`, + accessToken: "at", + refreshToken: "rt", + redirectUri: "https://example.com/callback", + expiresAt: new Date(Date.now() + 3600_000), + }, + }); + + await prisma.hyundaiVehicleLink.create({ + data: { vehicleId, accountLinkId: accountLink.id, hyundaiCarId: `car-${suffix}` }, + }); + }); + + afterEach(async () => { + await prisma.vehicle.delete({ where: { id: vehicleId } }).catch(() => {}); + await prisma.user.delete({ where: { id: userId } }).catch(() => {}); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + it("deletes the account link (and cascaded vehicle link) on account delete", async () => { + const link = await prisma.hyundaiAccountLink.findUniqueOrThrow({ where: { userId } }); + + const res = await app.inject({ + method: "POST", + url: "/api/hyundai/webhook", + payload: { type: "account", action: "delete", userId: link.hyundaiUserId }, + }); + expect(res.statusCode).toBe(200); + + expect(await prisma.hyundaiAccountLink.findUnique({ where: { userId } })).toBeNull(); + expect(await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } })).toBeNull(); + }); + + it("deletes only the vehicle link on vehicle delete, leaving the account link intact", async () => { + const link = await prisma.hyundaiVehicleLink.findUniqueOrThrow({ where: { vehicleId } }); + + const res = await app.inject({ + method: "POST", + url: "/api/hyundai/webhook", + payload: { type: "vehicle", action: "delete", carId: link.hyundaiCarId }, + }); + expect(res.statusCode).toBe(200); + + expect(await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } })).toBeNull(); + expect(await prisma.hyundaiAccountLink.findUnique({ where: { userId } })).not.toBeNull(); + }); + + it("deletes the vehicle link on a third-party-agreement rejection", async () => { + const link = await prisma.hyundaiVehicleLink.findUniqueOrThrow({ where: { vehicleId } }); + + const res = await app.inject({ + method: "POST", + url: "/api/hyundai/webhook", + payload: { type: "agreement", action: "reject", carId: link.hyundaiCarId }, + }); + expect(res.statusCode).toBe(200); + + expect(await prisma.hyundaiVehicleLink.findUnique({ where: { vehicleId } })).toBeNull(); + }); + + it("ignores unrecognized payloads without erroring", async () => { + const res = await app.inject({ method: "POST", url: "/api/hyundai/webhook", payload: { type: "unknown" } }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/src/routes/hyundaiWebhook.ts b/apps/api/src/routes/hyundaiWebhook.ts new file mode 100644 index 0000000..5470697 --- /dev/null +++ b/apps/api/src/routes/hyundaiWebhook.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from "fastify"; +import { prisma } from "../lib/prisma.js"; + +// 현대 Hyundai Developers의 "데이터 조회 불가 상태 알림" 콜백 — 콘솔의 +// "설정 - 데이터 API" 페이지에 이 라우트 URL을 Callback URL로 등록해야 호출된다. +// 계정 삭제/차량 삭제/제3자 제공 동의 철회 시 즉시 호출되며, 개인정보보호법상 +// 통지 즉시 관련 데이터를 삭제해야 한다. 규격서에 별도 인증 헤더가 명시돼 있지 +// 않아 이 라우트는 JWT 인증 없이 공개돼 있다(ingestRoutes와 동일한 성격). +export async function hyundaiWebhookRoutes(app: FastifyInstance) { + app.post("/", async (request, reply) => { + const { type, action, userId, carId } = request.body as { + type?: string; + action?: string; + userId?: string; + carId?: string; + }; + + if (type === "account" && action === "delete" && userId) { + // HyundaiAccountLink 삭제는 onDelete: Cascade로 딸린 HyundaiVehicleLink도 함께 지운다. + await prisma.hyundaiAccountLink.deleteMany({ where: { hyundaiUserId: userId } }); + } else if ((type === "vehicle" || type === "agreement") && action && carId) { + await prisma.hyundaiVehicleLink.deleteMany({ where: { hyundaiCarId: carId } }); + } + + return reply.code(200).send({ status: "ok" }); + }); +} diff --git a/apps/web/app/integrations/page.tsx b/apps/web/app/integrations/page.tsx index aa2ac07..a9fa75d 100644 --- a/apps/web/app/integrations/page.tsx +++ b/apps/web/app/integrations/page.tsx @@ -58,6 +58,16 @@ const SETTING_META: Record< labelKey: "vapidSubjectLabel", helpKey: "vapidSubjectHelp", }, + HYUNDAI_CLIENT_ID: { + labelKey: "hyundaiClientIdLabel", + helpKey: "hyundaiClientIdHelp", + signupUrl: "https://developers.hyundai.com", + signupLabelKey: "integrationLinkHyundai", + }, + HYUNDAI_CLIENT_SECRET: { + labelKey: "hyundaiClientSecretLabel", + helpKey: "hyundaiClientSecretHelp", + }, }; // 오피넷/EV충전소는 "연료·충전", 지도 3종은 "지도", VAPID 쌍+발신자는 "알림"으로 묶어 @@ -65,6 +75,7 @@ const SETTING_META: Record< const GROUPS: { key: string; titleKey: TranslationKey; keys: string[] }[] = [ { key: "fuel", titleKey: "integrationGroupFuel", keys: ["OPINET_API_KEY", "EV_CHARGER_API_KEY", "EV_CHARGER_API_KEY_EXPIRES_AT"] }, { key: "map", titleKey: "integrationGroupMap", keys: ["KAKAO_MAP_APP_KEY", "NAVER_MAP_CLIENT_ID", "TMAP_APP_KEY"] }, + { key: "connectedCar", titleKey: "integrationGroupConnectedCar", keys: ["HYUNDAI_CLIENT_ID", "HYUNDAI_CLIENT_SECRET"] }, { key: "notification", titleKey: "integrationGroupNotification", keys: ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"] }, ]; diff --git a/apps/web/app/profile/hyundai/callback/page.tsx b/apps/web/app/profile/hyundai/callback/page.tsx new file mode 100644 index 0000000..502c1d0 --- /dev/null +++ b/apps/web/app/profile/hyundai/callback/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Suspense, useEffect, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { apiFetch } from "../../../../lib/api"; +import { useSettings } from "../../../../lib/i18n/settings-context"; +import { PageLoader } from "../../../../components/PageLoader"; + +// 현대 로그인(1단계)과 개인정보 제공 동의(2단계) 둘 다 이 페이지로 돌아온다 — +// 로그인은 ?code=&state=, 동의는 ?userId=&state=로 구분된다. 둘 다 서버 API를 +// 호출해 마무리한 뒤 /profile로 돌아가 결과를 토스트로 보여준다. +export default function HyundaiCallbackPage() { + return ( + + + + ); +} + +function HyundaiCallbackInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useSettings(); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + + (async () => { + const code = searchParams.get("code"); + const userId = searchParams.get("userId"); + const state = searchParams.get("state"); + const errCode = searchParams.get("errCode"); + + if (errCode) { + router.replace("/profile?hyundai=error"); + return; + } + + if (code) { + const redirectUri = `${window.location.origin}/profile/hyundai/callback`; + const res = await apiFetch("/api/hyundai/link", { + method: "POST", + body: JSON.stringify({ code, redirectUri }), + }); + router.replace(res.ok ? "/profile?hyundai=linked" : "/profile?hyundai=error"); + return; + } + + if (userId && state) { + const res = await apiFetch("/api/hyundai/consent/complete", { + method: "POST", + body: JSON.stringify({ userId, state }), + }); + router.replace(res.ok ? "/profile?hyundai=consented" : "/profile?hyundai=error"); + return; + } + + router.replace("/profile?hyundai=error"); + })(); + }, [searchParams, router]); + + return ; +} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index f9d9837..4278ca8 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -6,6 +6,7 @@ import { apiFetch } from "../../lib/api"; import { useAuth } from "../../lib/auth-context"; import { useSettings } from "../../lib/i18n/settings-context"; import { useToast } from "../../lib/toast-context"; +import { useConfirm } from "../../lib/confirm-context"; import { PushNotificationSettings } from "../../components/PushNotificationSettings"; import { useMapProviders } from "../../lib/maps/useMapProviders"; import { isMapProvider, MAP_PROVIDER_STORAGE_KEY } from "../../lib/maps/types"; @@ -36,6 +37,7 @@ export default function ProfilePage() { t, } = useSettings(); const { showToast } = useToast(); + const confirm = useConfirm(); const router = useRouter(); const mapConfig = useMapProviders(); @@ -49,10 +51,89 @@ export default function ProfilePage() { const [error, setError] = useState(""); const [mapProviderPref, setMapProviderPref] = useState("auto"); + const [hyundaiConfigured, setHyundaiConfigured] = useState(false); + const [hyundaiLinked, setHyundaiLinked] = useState(false); + const [hyundaiConsentGranted, setHyundaiConsentGranted] = useState(false); + const [hyundaiActionLoading, setHyundaiActionLoading] = useState(false); + + async function loadHyundaiStatus() { + const [configuredRes, accountRes] = await Promise.all([ + apiFetch("/api/hyundai/configured"), + apiFetch("/api/hyundai/account"), + ]); + if (configuredRes.ok) setHyundaiConfigured((await configuredRes.json()).configured); + if (accountRes.ok) { + const data = await accountRes.json(); + setHyundaiLinked(data.linked); + setHyundaiConsentGranted(data.consentGranted); + } + } + useEffect(() => { requireAuth(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + loadHyundaiStatus(); + + const params = new URLSearchParams(window.location.search); + const result = params.get("hyundai"); + if (result) { + if (result === "linked") showToast(t("hyundaiToastLinked"), "success"); + else if (result === "consented") showToast(t("hyundaiToastConsented"), "success"); + else showToast(t("hyundaiToastError"), "error"); + router.replace("/profile"); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + async function handleHyundaiLink() { + setHyundaiActionLoading(true); + try { + const redirectUri = `${window.location.origin}/profile/hyundai/callback`; + const res = await apiFetch(`/api/hyundai/authorize-url?redirectUri=${encodeURIComponent(redirectUri)}`); + if (res.ok) { + const { url } = await res.json(); + window.location.href = url; + } else { + showToast(t("hyundaiToastError"), "error"); + } + } finally { + setHyundaiActionLoading(false); + } + } + + async function handleHyundaiConsent() { + setHyundaiActionLoading(true); + try { + const res = await apiFetch("/api/hyundai/data-consent-url"); + if (res.ok) { + const { url } = await res.json(); + window.location.href = url; + } else { + showToast(t("hyundaiToastError"), "error"); + } + } finally { + setHyundaiActionLoading(false); + } + } + + async function handleHyundaiUnlink() { + if (!(await confirm(t("hyundaiUnlinkConfirm"), { confirmLabel: t("hyundaiUnlinkButton") }))) return; + setHyundaiActionLoading(true); + try { + const res = await apiFetch("/api/hyundai/account", { method: "DELETE" }); + if (res.ok) { + setHyundaiLinked(false); + setHyundaiConsentGranted(false); + showToast(t("toastSaved"), "success"); + } else { + showToast(t("toastError"), "error"); + } + } finally { + setHyundaiActionLoading(false); + } + } + useEffect(() => { const saved = localStorage.getItem(MAP_PROVIDER_STORAGE_KEY); setMapProviderPref(saved && isMapProvider(saved) ? saved : "auto"); @@ -202,6 +283,76 @@ export default function ProfilePage() { + {hyundaiConfigured && ( + <> +

+ {t("hyundaiSectionHeading")} + + {t("hyundaiBetaBadge")} + +

+
+

{t("hyundaiBetaNotice")}

+ + {!hyundaiLinked && ( + <> +

{t("hyundaiNotLinked")}

+ + + )} + + {hyundaiLinked && !hyundaiConsentGranted && ( + <> +

{t("hyundaiConsentNeeded")}

+
+ + +
+ + )} + + {hyundaiLinked && hyundaiConsentGranted && ( + <> +

+ {t("hyundaiConsentGranted")} +

+ + + )} +
+ + )} +

{t("preferences")}

diff --git a/apps/web/app/vehicles/[id]/integration/page.tsx b/apps/web/app/vehicles/[id]/integration/page.tsx index 67b828e..d212a67 100644 --- a/apps/web/app/vehicles/[id]/integration/page.tsx +++ b/apps/web/app/vehicles/[id]/integration/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; +import Link from "next/link"; import { apiFetch } from "../../../../lib/api"; import { useAuth } from "../../../../lib/auth-context"; import { useSettings } from "../../../../lib/i18n/settings-context"; @@ -9,13 +10,14 @@ import { PageLoader } from "../../../../components/PageLoader"; import { useToast } from "../../../../lib/toast-context"; import { useConfirm } from "../../../../lib/confirm-context"; import type { Vehicle } from "../../../../lib/types"; +import type { HyundaiVehicleSummary, HyundaiMileage } from "@garage/shared"; import { SmartphoneIcon, HomeIcon } from "../../../../components/icons"; export default function VehicleIntegrationPage() { const params = useParams<{ id: string }>(); const vehicleId = params.id; const { isAdmin } = useAuth(); - const { t } = useSettings(); + const { t, formatDistance } = useSettings(); const { showToast } = useToast(); const confirm = useConfirm(); @@ -23,14 +25,50 @@ export default function VehicleIntegrationPage() { const [loading, setLoading] = useState(true); const [resettingToken, setResettingToken] = useState(false); + const [hyundaiConfigured, setHyundaiConfigured] = useState(false); + const [hyundaiAccountReady, setHyundaiAccountReady] = useState(false); + const [hyundaiCarId, setHyundaiCarId] = useState(null); + const [hyundaiCandidates, setHyundaiCandidates] = useState([]); + const [hyundaiSelectedCarId, setHyundaiSelectedCarId] = useState(""); + const [hyundaiMileage, setHyundaiMileage] = useState(null); + const [hyundaiActionLoading, setHyundaiActionLoading] = useState(false); + async function load() { const res = await apiFetch(`/api/vehicles/${vehicleId}`); if (res.ok) setVehicle(await res.json()); setLoading(false); } + async function loadHyundaiState() { + const [configuredRes, accountRes, linkRes] = await Promise.all([ + apiFetch("/api/hyundai/configured"), + apiFetch("/api/hyundai/account"), + apiFetch(`/api/hyundai/vehicles/${vehicleId}/link`), + ]); + + const configured = configuredRes.ok ? (await configuredRes.json()).configured : false; + setHyundaiConfigured(configured); + if (!configured) return; + + const account = accountRes.ok ? await accountRes.json() : { linked: false, consentGranted: false }; + const ready = account.linked && account.consentGranted; + setHyundaiAccountReady(ready); + + const link = linkRes.ok ? await linkRes.json() : { hyundaiCarId: null }; + setHyundaiCarId(link.hyundaiCarId); + + if (link.hyundaiCarId) { + const mileageRes = await apiFetch(`/api/hyundai/vehicles/${vehicleId}/mileage`); + if (mileageRes.ok) setHyundaiMileage(await mileageRes.json()); + } else if (ready) { + const vehiclesRes = await apiFetch("/api/hyundai/vehicles"); + if (vehiclesRes.ok) setHyundaiCandidates(await vehiclesRes.json()); + } + } + useEffect(() => { load(); + loadHyundaiState(); }, [vehicleId]); // eslint-disable-line react-hooks/exhaustive-deps async function handleCopyToken() { @@ -62,70 +100,110 @@ export default function VehicleIntegrationPage() { } } + async function handleHyundaiLinkVehicle() { + if (!hyundaiSelectedCarId) return; + setHyundaiActionLoading(true); + try { + const res = await apiFetch(`/api/hyundai/vehicles/${vehicleId}/link`, { + method: "PUT", + body: JSON.stringify({ carId: hyundaiSelectedCarId }), + }); + if (res.ok) { + showToast(t("toastSaved"), "success"); + await loadHyundaiState(); + } else { + showToast(t("toastError"), "error"); + } + } finally { + setHyundaiActionLoading(false); + } + } + + async function handleHyundaiUnlinkVehicle() { + if (!(await confirm(t("hyundaiUnlinkConfirm"), { confirmLabel: t("hyundaiVehicleUnlinkButton") }))) return; + setHyundaiActionLoading(true); + try { + const res = await apiFetch(`/api/hyundai/vehicles/${vehicleId}/link`, { method: "DELETE" }); + if (res.ok || res.status === 204) { + setHyundaiCarId(null); + setHyundaiMileage(null); + showToast(t("toastSaved"), "success"); + await loadHyundaiState(); + } else { + showToast(t("toastError"), "error"); + } + } finally { + setHyundaiActionLoading(false); + } + } + if (loading) return ; if (!isAdmin || !vehicle) return null; + const linkedCandidate = hyundaiCandidates.find((c) => c.carId === hyundaiCarId); + return ( -
-

{t("obdGpsIntegrationTitle")}

- -
-
-
-
- {t("apiTokenLabel")}: - - {vehicle.apiToken || t("apiTokenNotIssued")} - -
-
- - + <> +
+

{t("obdGpsIntegrationTitle")}

+ +
+
+
+
+ {t("apiTokenLabel")}: + + {vehicle.apiToken || t("apiTokenNotIssued")} + +
+
+ + +
-
-
-

- Torque Pro 앱 연동 방법 -

-
    -
  1. Torque Pro 앱 설정 > Web Queue / OBD Web Server 메뉴로 이동합니다.
  2. -
  3. Send data to web server를 활성화합니다.
  4. -
  5. Web Server URL 항목에 아래 주소를 입력합니다: -
    - {typeof window !== "undefined" ? `${window.location.origin}/api/ingest/obd?token=${vehicle.apiToken}` : ""} -
    -
  6. -
-
+
+

+ Torque Pro 앱 연동 방법 +

+
    +
  1. Torque Pro 앱 설정 > Web Queue / OBD Web Server 메뉴로 이동합니다.
  2. +
  3. Send data to web server를 활성화합니다.
  4. +
  5. Web Server URL 항목에 아래 주소를 입력합니다: +
    + {typeof window !== "undefined" ? `${window.location.origin}/api/ingest/obd?token=${vehicle.apiToken}` : ""} +
    +
  6. +
+
-
-

- Home Assistant (HA) / 범용 REST API 연동 -

-

- 아래의 HTTP POST 규격으로 차량 주행 텔레메트리 정보를 실시간으로 인제스트할 수 있습니다: -

-
- POST {typeof window !== "undefined" ? `${window.location.origin}/api/ingest/telemetry` : ""}
- Header: Authorization: Bearer {vehicle.apiToken}
- Body (JSON): -
+          
+

+ Home Assistant (HA) / 범용 REST API 연동 +

+

+ 아래의 HTTP POST 규격으로 차량 주행 텔레메트리 정보를 실시간으로 인제스트할 수 있습니다: +

+
+ POST {typeof window !== "undefined" ? `${window.location.origin}/api/ingest/telemetry` : ""}
+ Header: Authorization: Bearer {vehicle.apiToken}
+ Body (JSON): +
 {JSON.stringify({
   speed: 65,
   rpm: 2000,
@@ -136,13 +214,116 @@ export default function VehicleIntegrationPage() {
   dtcCodes: "P0300",
   inVehicle: true
 }, null, 2)}
-            
-

- 모든 필드는 선택 사항입니다 — 보내는 것만 반영됩니다. odometer는 기존 값보다 클 때만 갱신되고, dtcCodes는 진단 코드 문자열입니다. -

+
+

+ 모든 필드는 선택 사항입니다 — 보내는 것만 반영됩니다. odometer는 기존 값보다 클 때만 갱신되고, dtcCodes는 진단 코드 문자열입니다. +

+
-
-
+ + + {hyundaiConfigured && ( +
+

+ {t("hyundaiVehicleLinkHeading")} + + {t("hyundaiBetaBadge")} + +

+ + {!hyundaiAccountReady && !hyundaiCarId && ( +

+ {t("hyundaiVehicleLinkHint")}{" "} + + {t("hyundaiVehicleLinkProfileLink")} + +

+ )} + + {hyundaiAccountReady && !hyundaiCarId && ( +
+ {hyundaiCandidates.length === 0 ? ( +

{t("hyundaiNoVehiclesFound")}

+ ) : ( + <> + + + + )} +
+ )} + + {hyundaiCarId && ( +
+

+ {t("hyundaiVehicleLinked", { name: linkedCandidate?.nickname || linkedCandidate?.model || hyundaiCarId })} +

+ {hyundaiMileage && ( +
+
+
{t("hyundaiMileageOdometer")}
+ {formatDistance(hyundaiMileage.odometerKm)} +
+ {hyundaiMileage.distanceToEmptyKm !== null && ( +
+
{t("hyundaiMileageDte")}
+ {formatDistance(hyundaiMileage.distanceToEmptyKm)} +
+ )} +
+ )} +
+ + +
+
+ )} +
+ )} + ); } diff --git a/apps/web/lib/i18n/translations.ts b/apps/web/lib/i18n/translations.ts index adbb15b..37c8b43 100644 --- a/apps/web/lib/i18n/translations.ts +++ b/apps/web/lib/i18n/translations.ts @@ -217,6 +217,32 @@ export const translations = { incorrectPassword: "현재 비밀번호가 올바르지 않습니다.", passwordMismatch: "비밀번호 변경 실패", changePasswordHeading: "비밀번호 변경", + hyundaiSectionHeading: "커넥티드카 (블루링크)", + hyundaiBetaBadge: "베타", + hyundaiBetaNotice: "실제 계정으로 아직 충분히 검증되지 않았습니다. 문제가 있으면 알려주세요.", + hyundaiNotConfigured: "관리자가 아직 설정하지 않았습니다.", + hyundaiNotLinked: "연동된 계정이 없습니다. 연동하면 OBD 동글 없이 실제 주행거리·차량상태를 가져올 수 있어요.", + hyundaiLinkButton: "블루링크 계정 연동하기", + hyundaiConsentNeeded: "데이터 조회를 위해 개인정보 제공 동의가 한 단계 더 필요합니다.", + hyundaiConsentButton: "개인정보 제공 동의하기", + hyundaiConsentGranted: "연동 완료 — 차량 데이터를 조회할 수 있습니다.", + hyundaiUnlinkButton: "연동 해제", + hyundaiUnlinkConfirm: "블루링크 연동을 해제하시겠습니까? 연결된 차량 매칭도 함께 해제됩니다.", + hyundaiLinking: "연동 처리 중...", + hyundaiToastLinked: "블루링크 계정이 연동되었습니다.", + hyundaiToastConsented: "개인정보 제공 동의가 완료되었습니다.", + hyundaiToastError: "연동에 실패했습니다.", + hyundaiVehicleLinkHeading: "블루링크 차량 연동", + hyundaiVehicleLinkHint: "먼저 프로필에서 블루링크 계정 연동과 개인정보 제공 동의를 완료해주세요.", + hyundaiVehicleLinkProfileLink: "프로필로 이동", + hyundaiSelectCarPlaceholder: "차량 선택", + hyundaiVehicleLinkButton: "이 차량에 연결", + hyundaiVehicleLinked: "연결됨: {{name}}", + hyundaiVehicleUnlinkButton: "연결 해제", + hyundaiNoVehiclesFound: "연동된 계정에 등록된 차량이 없습니다.", + hyundaiMileageOdometer: "누적 주행거리", + hyundaiMileageDte: "주행가능거리", + hyundaiRefreshButton: "새로고침", attachmentLabel: "영수증 및 첨부파일", uploading: "업로드 중...", search: "검색", @@ -339,7 +365,13 @@ export const translations = { integrationsIntro: "외부 API 연동 키를 관리합니다. 여기서 저장한 값은 서버 재시작 없이 즉시 적용되며, 백업 파일에는 포함되지 않습니다.", integrationGroupFuel: "연료 · 충전", integrationGroupMap: "지도", + integrationGroupConnectedCar: "커넥티드카", integrationGroupNotification: "알림", + hyundaiClientIdLabel: "현대 Hyundai Developers Client ID", + hyundaiClientIdHelp: "developers.hyundai.com 서비스 콘솔에서 발급받은 Client ID를 입력하세요. 블루링크 연동 차량의 주행거리·주차위치·운전습관을 가져오는 데 쓰입니다.", + hyundaiClientSecretLabel: "현대 Hyundai Developers Client Secret", + hyundaiClientSecretHelp: "Client ID와 함께 발급받은 Client Secret을 입력하세요.", + integrationLinkHyundai: "developers.hyundai.com에서 발급받기", opinetApiKeyLabel: "오피넷(Opinet) 유가정보 API 키", opinetApiKeyHelp: "www.opinet.co.kr 오픈API에서 발급받은 키를 입력하세요. 비워두면 빠른 입력 화면의 주변 주유소 조회가 예시 데이터로 대체됩니다.", evChargerApiKeyLabel: "환경부 전기차 충전소 정보 API 키", @@ -658,6 +690,32 @@ export const translations = { incorrectPassword: "Incorrect current password.", passwordMismatch: "Failed to change password.", changePasswordHeading: "Change Password", + hyundaiSectionHeading: "Connected Car (Bluelink)", + hyundaiBetaBadge: "Beta", + hyundaiBetaNotice: "Not yet fully verified with a real account. Please report any issues.", + hyundaiNotConfigured: "Not configured by the admin yet.", + hyundaiNotLinked: "No account linked. Linking lets you pull real mileage and vehicle status without an OBD dongle.", + hyundaiLinkButton: "Link Bluelink Account", + hyundaiConsentNeeded: "One more step: data-sharing consent is required before we can read vehicle data.", + hyundaiConsentButton: "Grant Data Consent", + hyundaiConsentGranted: "Linked — vehicle data is available.", + hyundaiUnlinkButton: "Unlink", + hyundaiUnlinkConfirm: "Unlink your Bluelink account? Any vehicle matched to it will be unlinked too.", + hyundaiLinking: "Linking...", + hyundaiToastLinked: "Bluelink account linked.", + hyundaiToastConsented: "Data consent granted.", + hyundaiToastError: "Linking failed.", + hyundaiVehicleLinkHeading: "Bluelink Vehicle Link", + hyundaiVehicleLinkHint: "Link your Bluelink account and grant data consent in your profile first.", + hyundaiVehicleLinkProfileLink: "Go to profile", + hyundaiSelectCarPlaceholder: "Select a vehicle", + hyundaiVehicleLinkButton: "Link this vehicle", + hyundaiVehicleLinked: "Linked: {{name}}", + hyundaiVehicleUnlinkButton: "Unlink", + hyundaiNoVehiclesFound: "No vehicles found on the linked account.", + hyundaiMileageOdometer: "Odometer", + hyundaiMileageDte: "Distance to empty", + hyundaiRefreshButton: "Refresh", attachmentLabel: "Receipt / Attachment", uploading: "Uploading...", search: "Search", @@ -780,7 +838,13 @@ export const translations = { integrationsIntro: "Manage external API integration keys. Values saved here take effect immediately without a server restart, and are never included in backup files.", integrationGroupFuel: "Fuel & Charging", integrationGroupMap: "Maps", + integrationGroupConnectedCar: "Connected Car", integrationGroupNotification: "Notifications", + hyundaiClientIdLabel: "Hyundai Developers Client ID", + hyundaiClientIdHelp: "Enter the Client ID issued from the developers.hyundai.com service console. Used to pull mileage, last-parked location, and driving habit data from Bluelink-linked vehicles.", + hyundaiClientSecretLabel: "Hyundai Developers Client Secret", + hyundaiClientSecretHelp: "Enter the Client Secret issued together with the Client ID.", + integrationLinkHyundai: "Get one at developers.hyundai.com", opinetApiKeyLabel: "Opinet Fuel Price API Key", opinetApiKeyHelp: "Enter the key issued from the Opinet (www.opinet.co.kr) open API. If left empty, nearby gas station lookups in Quick Log fall back to sample data.", evChargerApiKeyLabel: "EV Charging Station Info API Key (K-eco)", diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 7625608..b2e5594 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -21,6 +21,7 @@ Implementation status reflects the current source tree. Keys managed in the admi | [Vehicle records REST API](#11-vehicle-records-rest-api-fuel--maintenance) | external → Garage | **Available** | Garage user JWT (`/api/auth/login`) | Fuel logs, maintenance records, odometer side-effects | | [PWA Web Push](#12-pwa-web-push) | Garage → client | **Available** | `VAPID_*` env vars | Due maintenance/admin reminder notifications | | [API Explorer](#13-api-explorer) | (developer tool) | **Available** | ADMIN login | Browse & test every REST endpoint from the web UI | +| [Hyundai Developers (Bluelink)](#15-hyundai-developers-connected-car-api) | external → Garage | Wired against spec, not live-tested | `/integrations` (`HYUNDAI_CLIENT_ID`/`_SECRET`) | Mileage, EV battery/charging, warning lights — no OBD dongle needed | --- @@ -908,6 +909,82 @@ data.go.kr key applications default to a **2-year validity period** and expire a --- +## 15. Hyundai Developers (connected car API) + +| Field | Value | +|---|---| +| Status | **Wired against the confirmed API spec, not yet live-tested** — every endpoint below is implemented and unit-tested against the console's own API specification pages. No account has actually completed the full OAuth + data-consent flow yet, so nothing has been verified against a real response. | +| Setting keys | `HYUNDAI_CLIENT_ID`, `HYUNDAI_CLIENT_SECRET` | +| Where to set | `/integrations` UI | +| Issuer | [developers.hyundai.com](https://developers.hyundai.com) ("Hyundai Developers") | +| Implementation | `apps/api/src/lib/hyundai.ts` (+ `hyundai.test.ts`), `apps/api/src/lib/hyundaiToken.ts`, `apps/api/src/routes/hyundai.ts`, `apps/api/src/routes/hyundaiWebhook.ts`, `apps/api/src/jobs/hyundaiSync.ts` | + +### Why + +Reviewed as an alternative to OBD-dongle-based ingest (see [OBD app](#2-obd-app-torque-pro)): +domestic (Korea-registered) Bluelink-connected vehicles expose real odometer, EV battery/charging +state, and dashboard warning lights directly from Hyundai's own cloud — no phone app, no Bluetooth +dongle. Note this only gives periodic odometer/status *snapshots* (updated by the car itself at +ignition-off) — not per-trip route/duration/speed history like the OBD/GPS ingest path produces; +the two are complementary, not a replacement for one another. + +### Automatic odometer sync + +`apps/api/src/jobs/hyundaiSync.ts` runs once on server boot and twice daily (07:00, 19:00 — matching +the `reminders` job's cadence, chosen because Bluelink odometer data only updates at ignition-off, +so polling more often has no effect). For every `HyundaiVehicleLink`, it fetches mileage and bumps +`Vehicle.odometer` **only if the fetched value is higher** than what's stored — the same +non-destructive rule the OBD webhook ingest uses (`bumpOdometerIfHigher`), so a more-recent manual +entry is never overwritten by a stale Bluelink read. + +### Data model + +- `Setting` (`HYUNDAI_CLIENT_ID` / `HYUNDAI_CLIENT_SECRET`) — app-level OAuth client credentials, admin-managed, excluded from backup like other integration keys. +- `HyundaiAccountLink` — one row per Garage `User` who has linked their own Hyundai account (access/refresh token, the `redirectUri` used at login so refresh can reuse it, and `hyundaiUserId` — Hyundai's own user id from `/user/profile`, used to match the deletion webhook below). Personal, not admin-scoped — each family member links their own Bluelink account. +- `HyundaiVehicleLink` — maps one Garage `Vehicle` to one Hyundai `carId`, referencing whichever `HyundaiAccountLink` owns it (not necessarily the requester — token lookups always resolve through the vehicle's own link, per `getValidAccessTokenForVehicleLink` in the route file). + +### API Garage exposes (`/api/hyundai/*`, all JWT-authenticated) + +| Method | Path | Description | +|---|---|---| +| `GET` | `/configured` | Whether admin has set Client ID/Secret | +| `GET` | `/account` | Whether the current user has linked their own Hyundai account | +| `GET` | `/authorize-url?redirectUri=` | Login URL to redirect the user to | +| `POST` | `/link` | Exchange an authorization code for tokens, store against the current user | +| `DELETE` | `/account` | Unlink the current user's Hyundai account (revokes the token and withdraws data consent) | +| `GET` | `/vehicles` | List the linked account's Hyundai vehicles (candidates for matching to a Garage vehicle) | +| `PUT` | `/vehicles/:vehicleId/link` | Link a Garage vehicle to a Hyundai `carId` | +| `DELETE` | `/vehicles/:vehicleId/link` | Unlink | +| `GET` | `/vehicles/:vehicleId/mileage` | Odometer + distance-to-empty | +| `GET` | `/vehicles/:vehicleId/status` | Warning lights (7 types) | +| `GET` | `/vehicles/:vehicleId/driving-habit` | Not yet available — see below | + +`POST /api/hyundai/webhook` (no JWT, public) is Hyundai's "데이터 조회 불가 상태 알림" callback — +register it as the Callback URL in the console's "설정 - 데이터 API" page. On account deletion, +vehicle deletion, or consent withdrawal it deletes the corresponding `HyundaiAccountLink`/ +`HyundaiVehicleLink` row, per the 개인정보보호법 requirement to purge data immediately on notice. + +### Confirmed against the console's own API specification (not just the walkthrough guide) + +- Login: `GET https://prd.kr-ccapi.hyundai.com/api/v1/user/oauth2/authorize` (`response_type`, `client_id`, `redirect_uri`, `state`) +- Token issue/refresh/revoke: `POST https://prd.kr-ccapi.hyundai.com/api/v1/user/oauth2/token`, `Authorization: Basic base64(client_id:client_secret)`, form-encoded, `grant_type` = `authorization_code` | `refresh_token` | `delete`. Access tokens last 24h, refresh tokens 1 year (server-controlled; not hardcoded here since `expires_in` is read from the actual response). +- User profile: `GET https://prd.kr-ccapi.hyundai.com/api/v1/user/profile` → `{id, email, name, mobileNum, birthdate, lang, social}` (the field is `id`, not `userId`). +- Data consent: `POST https://dev.kr-ccapi.hyundai.com/api/v1/car-service/terms/agreement` (`token`, `state`) — redirect-based like login, not a plain server-to-server call; required before *any* data endpoint works (otherwise every call fails with `5005 No Agreement Error`). Withdrawal: `GET .../api/v1/car-service/terms/reject`. +- Vehicle list: `GET https://dev.kr-ccapi.hyundai.com/api/v1/car/profile/carlist` → `{cars: [{carId, carNickname, carType, carName, carSellname}]}`. +- Connected-service subscription dates: `GET .../api/v1/car/profile/:carId/contract` → `{subscribeDate, endDate}` (YYYYMMDD). +- Mileage: `GET .../api/v1/car/status/:carId/dte` → `{value, unit, timestamp}`; `GET .../api/v1/car/status/:carId/odometer` → `{odometers: [{value, unit, date, timestamp}]}`. Both use the same unit code (0:feet, 1:km, 2:meter, 3:miles). +- EV battery/charging: `GET .../ev/battery` → `{soc}`; `GET .../ev/charging` → `{batteryPlugin, batteryCharge, soc, targetSOC, remainTime}`. +- Warning lights (7): `GET .../api/v1/car/status/warning/:carId/{lowFuel|tirePressure|lampWire|smartKeyBattery|washerFluid|breakOil|engineOil}` → `{status: boolean}` (note `breakOil`, not `brakeOil` — that's the real path). +- Every endpoint's error body is `{errCode, errMsg, errId}` — `hyundai.ts`'s `describeError()` surfaces this in logs instead of a bare HTTP status. +- All data-api hosts are `dev.kr-ccapi.hyundai.com` — confirmed across every endpoint in the spec, so this is a fixed host rather than an environment flag. + +### Still not available + +- **Last-parked location** and **90-day driving-habit safety score** — no endpoint for either has appeared in the specification pages reviewed so far. `fetchVehicleStatus`'s `lastParkedLat`/`lastParkedLon` stay `null`, and `fetchDrivingHabit` returns `null` until an endpoint is found. +- No account has completed the OAuth + data-consent flow end-to-end yet, so the parsing logic above is verified against the spec's documented sample responses, not a live call. + +--- + ## Related docs - [ARCHITECTURE.md](./ARCHITECTURE.md) — system design & data flow diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0663ef0..c37363f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,4 +10,5 @@ export * from "./schemas/settings.js"; export * from "./schemas/maps.js"; export * from "./schemas/opinet.js"; export * from "./schemas/evCharger.js"; +export * from "./schemas/hyundai.js"; export * from "./gamification.js"; diff --git a/packages/shared/src/schemas/hyundai.ts b/packages/shared/src/schemas/hyundai.ts new file mode 100644 index 0000000..7df6789 --- /dev/null +++ b/packages/shared/src/schemas/hyundai.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +// Hyundai Developers(developers.hyundai.com) 커넥티드카 API 연동 타입. +// 정확한 필드는 콘솔의 API 규격서 확인 후 조정될 수 있다 — 지금은 리뷰 단계에서 +// 확인된 5개 데이터 API 카테고리(제원/운행정보/주행거리/차량상태/운전습관) 기준. + +export const hyundaiVehicleSummarySchema = z.object({ + carId: z.string(), + nickname: z.string().nullable(), + model: z.string().nullable(), +}); + +export const hyundaiMileageSchema = z.object({ + odometerKm: z.number(), + distanceToEmptyKm: z.number().nullable(), +}); + +export const hyundaiVehicleStatusSchema = z.object({ + lastParkedLat: z.number().nullable(), + lastParkedLon: z.number().nullable(), + warnings: z.array(z.string()), +}); + +export const hyundaiDrivingHabitSchema = z.object({ + safetyScore: z.number(), + periodDays: z.number(), +}); + +export type HyundaiVehicleSummary = z.infer; +export type HyundaiMileage = z.infer; +export type HyundaiVehicleStatus = z.infer; +export type HyundaiDrivingHabit = z.infer; diff --git a/packages/shared/src/schemas/settings.ts b/packages/shared/src/schemas/settings.ts index 2b6b9c2..49e1414 100644 --- a/packages/shared/src/schemas/settings.ts +++ b/packages/shared/src/schemas/settings.ts @@ -11,6 +11,8 @@ export const settingKeySchema = z.enum([ "KAKAO_MAP_APP_KEY", "NAVER_MAP_CLIENT_ID", "TMAP_APP_KEY", + "HYUNDAI_CLIENT_ID", + "HYUNDAI_CLIENT_SECRET", "VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT",