[Backend] 從 mock JSON 到可重現的 PostgreSQL Catalog
/ 12 min read
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);於終端機執行
# 安裝 backend 依賴npm --prefix backend install
# 依 Prisma migration 建立資料表DATABASE_URL=postgresql://postgres:postgres@localhost:5432/catalog_demo \ npm --prefix backend exec prisma migrate deploy
# 讀取 mocks/,整理後寫入 PostgreSQLDATABASE_URL=postgresql://postgres:postgres@localhost:5432/catalog_demo \ npm --prefix backend run import:mocks
# 啟動 Catalog APIDATABASE_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,而不是產品的資料庫