skip to content
BlogZzz
Table of Contents

前言

某專案一開始是用來展示前端畫面與互動流程,因此影片、人物與列表資料都是直接寫在前端 JSON 檔案中的 demo 資料

這不是忽略資料庫設計,而是先以 mock 驗證需要哪些欄位、列表與詳情如何呈現,不必先建立完整資料庫與 API

但當專案的目標從展示畫面,變成讓其他人能在本機重建、查詢與繼續擴充時,前端寫死的 JSON 就不太夠

它能讓列表出現,卻很難回答幾個實際問題:內容如何被查詢和篩選?不同語言的資料放在哪裡?首頁、排行和詳情頁出現的是不是同一筆內容?

因此,這次改造的目標是將這些前端 JSON 資料轉移到 PostgreSQL,並透過 API 讓前端取得 Catalog 資料

沒有直接刪掉 mock,而是把它從執行期資料來源,改成建立 demo 資料的輸入

mock JSON
import script
PostgreSQL ← NestJS Catalog API ← Next.js frontend

最後成果是:任何人都能在本機建立資料庫、匯入 demo 資料、啟動 API 和前端;而前端頁面讀取的是正式的 API,不再依賴散落的 JSON 檔案

本文以影片 Catalog 為例,說明資料模型、重複匯入策略與 API 邊界如何配合

本文會用到的專案目錄

以下不是完整目錄,只列出 mock 轉 SQL 這條資料流會碰到的檔案:

project/
├── mocks/ # 前端原本讀取的 demo JSON
│ └── videos/newest.en.json
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma # Prisma model:資料表和關聯定義
│ │ └── migrations/ # migrate deploy 依序執行的 SQL
│ ├── scripts/
│ │ └── import-mocks.ts # npm run import:mocks 的進入點
│ └── src/
│ ├── import/ # collect、normalize、merge、persist
│ ├── catalog/ # NestJS controller、service、repository
│ └── main.ts # 啟動 NestJS API
├── frontend/ # Next.js 專案
└── .env.local # BACKEND_API_URL 等前端環境設定

問題描述:mock 的資料,寫死在前端難以維護

以影片列表為例,mock 原本就是前端卡片所需的資料

它同時包含影片欄位和人物陣列,但沒有表達資料庫關聯:

檔案:mocks/videos/newest.en.json(簡化)

{
"data": {
"list": [
{
"id": 881401,
"url": "example-video",
"title": "Example video",
"description": "A short description.",
"views": 7665,
"duration": 1875,
"cover_path": "https://images.example.com/cover.jpg",
"has_subtitle": true,
"actors": [
{ "id": 90015, "name": "Example Studio", "url": "example-studio" }
]
}
]
}
}

同一支影片還可能出現在詳情、首頁、搜尋與排行榜 mock 中

匯入時會先依 id 收集與合併,再把影片和人物拆成各自的資料表與關聯

這裡有一個刻意的命名轉換:舊 mock 使用 actors,但這個欄位可能放的是演員、工作室或創作者,不一定是狹義的 actor

因此資料庫統一稱為 Performer;名稱與介紹這類會隨語系變動的欄位,則放在 PerformerTranslation

單一 JSON 檔案很容易理解,但資料一旦同時出現在清單、詳情、搜尋結果與首頁區塊,就會開始產生重複的 projection

之後只要修改其中一份,就可能出現畫面不一致,前端的 mock 也會逐漸變成隱形的資料庫

把資料放進 PostgreSQL 後,影片、相簿與文章有明確的 identity;分類、頻道、人物與標籤是關聯;翻譯則獨立保存

前端只需要請求它需要的列表或詳情,不必知道資料實際如何儲存

在 PostgreSQL 定義資料:用 Prisma 描述影片與翻譯

資料庫採用 PostgreSQL,並以 Prisma schema 管理 migration

以下是簡化後的影片、人物與翻譯模型:

檔案:backend/prisma/schema.prisma(簡化)

enum PublicationStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum ContentLanguage {
EN
CN
}
model Video {
id String @id @default(uuid()) @db.Uuid
publicId Int @unique
slug String @unique
status PublicationStatus @default(DRAFT)
views Int @default(0)
durationSeconds Int @default(0)
coverUrl String?
translations VideoTranslation[]
performers VideoPerformer[]
}
model VideoTranslation {
id String @id @default(uuid()) @db.Uuid
videoId String @db.Uuid
language ContentLanguage
title String
description String?
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
@@unique([videoId, language])
}
model Performer {
id String @id @default(uuid()) @db.Uuid
publicId Int @unique
slug String @unique
translations PerformerTranslation[]
videos VideoPerformer[]
}
model PerformerTranslation {
id String @id @default(uuid()) @db.Uuid
performerId String @db.Uuid
language ContentLanguage
name String
performer Performer @relation(fields: [performerId], references: [id], onDelete: Cascade)
@@unique([performerId, language])
}
model VideoPerformer {
videoId String @db.Uuid
performerId String @db.Uuid
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
performer Performer @relation(fields: [performerId], references: [id], onDelete: Cascade)
@@id([videoId, performerId])
}

publicId 保留前端既有資料的穩定 ID;內部則以 UUID 做關聯

標題和描述放在 translation table,因此新增語系不用複製一整筆影片或人物

VideoPerformer 則是影片和人物的多對多關聯,正好對應 mock 裡的 actors 陣列

我保留的三個決策

1. mock 是 seed,不是 fallback

mock 仍然很有價值:它讓 demo 資料可以被版本控制,也能在空資料庫中重建

但服務執行時不會在 API 失敗後偷偷改讀 mock;資料庫才是唯一的 runtime source

這讓錯誤能被清楚看見,而不是由過期資料掩蓋

2. 讓 importer 可以安全重跑

importer 會讀取 mock、整理重複出現的內容,再寫入資料庫

重跑相同資料不會一直新增重複資料;如果真的需要以 seed 重新覆蓋,才使用明確的 force 操作

這個設計讓「初始化 demo」和「日後人工編輯資料」不會互相踩到

寫入時使用 upsert,讓同一個 publicId 在首次匯入時建立、之後匯入時更新:

檔案:backend/src/import/import.repository.ts(簡化)

const video = await prisma.video.upsert({
where: { publicId: mock.id },
create: {
publicId: mock.id,
slug: mock.url,
status: "PUBLISHED",
views: mock.views,
durationSeconds: mock.duration,
coverUrl: mock.cover_path,
},
update: {
slug: mock.url,
views: mock.views,
durationSeconds: mock.duration,
coverUrl: mock.cover_path,
},
});

upsert 可以讀成「有就更新、沒有就新增」:它先用 publicId: mock.id 尋找資料庫中的影片

  • 第一次執行 import:找不到相同 publicId,使用 create 新增影片
  • 第二次執行 import:找到相同 publicId,使用 update 更新 mock 中可同步的欄位

因此同一份 mock 可以重複匯入,不會每次都新增一筆相同影片

publicId 是這段邏輯的關鍵;若改用會變動的 title 或 URL 當識別,資料就容易重複或錯誤覆寫

接著將 actors 寫入人物與關聯表;人物本身以其既有 ID 去重:

檔案:backend/src/import/import.repository.ts(簡化)

for (const actor of mock.actors) {
const performer = await prisma.performer.upsert({
where: { publicId: actor.id },
create: { publicId: actor.id, slug: actor.url },
update: { slug: actor.url },
});
await prisma.performerTranslation.upsert({
where: {
performerId_language: { performerId: performer.id, language: "EN" },
},
create: { performerId: performer.id, language: "EN", name: actor.name },
update: { name: actor.name },
});
await prisma.videoPerformer.upsert({
where: {
videoId_performerId: { videoId: video.id, performerId: performer.id },
},
create: { videoId: video.id, performerId: performer.id },
update: {},
});
}

實際執行匯入

有了 Prisma schema 與 importer 後,流程是先將 migration 套用到空的 PostgreSQL 資料庫,再執行 import script

以下以本機資料庫為例:

檔案:backend/package.json(scripts);於終端機執行

Terminal window
# 安裝 backend 依賴
npm --prefix backend install
# 依 Prisma migration 建立資料表
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/catalog_demo \
npm --prefix backend exec prisma migrate deploy
# 讀取 mocks/,整理後寫入 PostgreSQL
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/catalog_demo \
npm --prefix backend run import:mocks
# 啟動 Catalog API
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/catalog_demo \
npm --prefix backend run dev

這裡的 import:mocks 不是單純把每個 JSON 檔 insert 進去;它會先 collect、normalize、merge,再在 transaction 中寫入資料表與關聯

匯入完成後,前端設定 BACKEND_API_URL=http://localhost:3001,就會改由 API 取得 Catalog 資料

3. 保留前端既有的讀取方式

我沒有讓資料庫 schema 直接外洩給前端,而是在 API 的 mapper 統一輸出既有 response shape

換句話說,後端可以使用關聯表、translation table 與自己的命名;前端仍拿到穩定的列表、分頁、語系與詳情資料

這降低了改造風險,也讓 backend 未來可以演進,而不必每次都回頭大改頁面

用 NestJS 把資料庫接成 API

NestJS controller 只處理 HTTP 邊界,資料查詢放在 service

以下是影片詳情 endpoint 的簡化版本:

檔案:backend/src/catalog/controllers/video.controller.ts(簡化)

@Controller("videos")
export class VideoController {
constructor(private readonly catalogService: CatalogService) {}
@Get(":publicId")
async getVideo(@Param("publicId", ParseIntPipe) publicId: number) {
return this.catalogService.getVideo(publicId);
}
}

檔案:backend/src/catalog/catalog.service.ts(簡化)

@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
async getVideo(publicId: number) {
return this.prisma.video.findUniqueOrThrow({
where: { publicId },
include: { translations: true },
});
}
}

實作中還會在 service/mapper 處處理 locale、Published 可見性與 response shape

這個例子想呈現的是資料流:前端請求 API,controller 交給 service,service 再透過 Prisma 查 PostgreSQL

前端不再直接碰 JSON 檔案

範例專案因此多展示了什麼?

改造後,這個專案展示的已不只是 Next.js 畫面,而是一條完整但不過度複雜的資料流:

  • 前端負責頁面、SSR 與使用者體驗
  • NestJS 提供 versioned Catalog API
  • Prisma 與 PostgreSQL 保存內容和關聯資料
  • mock 作為可重複匯入的 demo seed

我認為這是很實用的中間點:不必一開始就打造完整 CMS 或後台,但也不讓範例專案停留在只能展示靜態畫面的程度

結語

把 mock 轉成 SQL,在於讓範例專案具備可重現性與清楚的資料邊界

其他人能理解它怎麼跑、能在本機建立同樣的資料,也能從這個基礎繼續加功能

mock 沒有被淘汰;它只是回到更適合的位置:作為 demo 的 seed,而不是產品的資料庫