Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- 주유 기록·영수증 첨부, 오피넷 주변 주유소(선택)
- 전기차 충전소 찾기(한국환경공단 API, 선택) — 주유소와 마찬가지로 거리순/가격순 검색, 지도에 번호 마커로 표시
- OBD 수집(Torque Pro), REST/WebSocket 텔레메트리, 자동 트립 분할
- 현대 블루링크 커넥티드카 연동(베타, 국내 전용) — OBD 동글 없이 실제 주행거리·주행가능거리·경고등 조회, 오도미터 자동 동기화. 가족 구성원 각자 프로필에서 본인 계정 연동
- 주행 리포트, 경로 지도 (OSM / 카카오 / 네이버 / T맵) 및 진행 방향 화살표, 주행 개별 메모 추가/편집 및 역지오코딩
- 대시보드 알림 배지 및 차량 요약 카드 (최근 주유 비용 포함)
- 차량별 관리 레벨·뱃지(게이미피케이션) 전용 화면
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "HyundaiAccountLink" ADD COLUMN "redirectUri" TEXT NOT NULL;

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "HyundaiAccountLink" ADD COLUMN "hyundaiUserId" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "HyundaiAccountLink_hyundaiUserId_key" ON "HyundaiAccountLink"("hyundaiUserId");

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "HyundaiAccountLink" ADD COLUMN "dataConsentGrantedAt" TIMESTAMP(3);

70 changes: 54 additions & 16 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -70,6 +71,7 @@ model Vehicle {
attachments Attachment[]
xpEvents XpEvent[]
badges VehicleBadge[]
hyundaiLink HyundaiVehicleLink?
}

// 관리를 잘 할수록(정시 정비, 꼼꼼한 기록, 좋은 연비) 쌓이는 경험치 내역.
Expand Down Expand Up @@ -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())
Expand All @@ -138,7 +176,7 @@ model TelemetryRaw {
}

model Trip {
id String @id @default(cuid())
id String @id @default(cuid())
vehicleId String
startTime DateTime
endTime DateTime?
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -115,6 +117,8 @@ export async function buildApp(): Promise<FastifyInstance> {
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" });
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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();

startReminderJob();
startTripJob();
startTelemetryRetentionJob();
startHyundaiSyncJob();

// 기존 차량 중 apiToken이 없는 차량에 대해 토큰을 생성해 준다 (하위 호환성).
async function backfillVehicleTokens() {
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/jobs/hyundaiSync.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../lib/hyundai.js")>();
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);
});
});
37 changes: 37 additions & 0 deletions apps/api/src/jobs/hyundaiSync.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
});
}
Loading