iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 16

Day 16|將Route Handlers與Prisma ORM徹底串接CRUD完整落地

  • 分享至 

  • xImage
  •  

在Day15中,我們完成了PrismaORM與PostgreSQL的Schema設計、Migration建立,以及SeedData寫入。

然而,我們在Day14實作的Next.jsRouteHandlers(app/api/skills/route.ts)依然是用記憶體陣列(skillsDb)在儲存與過濾資料。一旦伺服器重啟或部署,新增與修改的技能資料就會消失。

今天(Day16),我們將正式進行RouteHandlers底層的記憶體陣列徹底移除,全面替換為PrismaClient的資料庫查詢,為VibePulse實現完整的CRUD(Create,Read,Update,Delete)資料持久化!

1.架構轉換:從MemoryMock到DatabasePersistence
重構前的RouteHandlers是從記憶體陣列進行filter與push;重構後,我們將交由PostgreSQL資料庫處理條件查詢、排序與分頁,發揮索引(Indexes)與ORM的威力:

重構前(In-MemoryArray):
Client--->APIHandler--->Array.filter()/Array.unshift()--->Response

重構後(Prisma+PostgreSQLPersistence):
Client--->APIHandler--->ZodValidation--->PrismaORM--->PostgreSQLDB
2.實戰步驟1:建立Enum轉換與MapHelper
由於PrismaSchema中的Enum採用大寫蛇形(FRONTEND,LEARNING),而前端介面與APIPayload採用CamelCase/PascalCase(Frontend,learning),我們需要一個雙向轉譯層,確保API規格與資料庫表層解耦。

開啟Cursor,對著@lib/utils/skillMappers.ts發送Prompt:

@prisma/schema.prisma (參考Prisma Enum定義)

請幫我在`lib/utils/skillMappers.ts`建立前端與Prisma DB格式之間的Mapping Helper函數。

## 需求細節:
1.`toDbCategory` / `fromDbCategory`**:
   - 'Frontend' <-> 'FRONTEND'
   - 'Backend' <-> 'BACKEND'
   - 'AI' <-> 'AI'
   - 'DevOps' <-> 'DEVOPS'
2.`toDbStatus` / `fromDbStatus`**:
   - 'learning' <-> 'LEARNING'
   - 'mastered' <-> 'MASTERED'
   - 'planned' <-> 'PLANNED'
3.`formatSkillFromDb`**:將Prisma回傳的`Skill`DB物件轉譯為前端使用的JSON結構。

3.實戰步驟2:重構GET&POST(app/api/skills/route.ts)
現在,我們請AI把Day14的/app/api/skills/route.ts升級為PrismaORM版本。

發送Prompt對著@app/api/skills/route.ts:

@lib/prisma.ts
@lib/validations/skill.ts
@lib/api/response.ts
@lib/utils/skillMappers.ts

請幫我重構`app/api/skills/route.ts`中的`GET`與`POST`Handlers,將資料來源從記憶體陣列全面替換為`prisma.skill`!

需求說明:
1.`GET`端點(讀取技能列表):
-支援`category`(類別過濾)與`search`(關鍵字搜尋)。
-**Prisma查詢條件(`where`):
-若提供`category`,使用轉譯後的Enum進行精準比對。
-若提供`search`,使用`OR`條件搭配`title:{contains:search,mode:'insensitive'}`或`tags:{has:search}`查詢。
-**排序(`orderBy`)**:預設依`createdAt:'desc'`排序。
-將DB結果經由`formatSkillFromDb`格式化後,經由`successResponse`回傳。

2.`POST`端點(新增技能):
-使用Zod`createSkillSchema.safeParse(body)`驗證。
-驗證通過後,調用`prisma.skill.create()`寫入PostgreSQL。
-回傳`201Created`與新建構的技能資料。
-包含完整的`try-catch`例外處理與500錯誤攔截。

AI產出的app/api/skills/route.ts:

import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { createSkillSchema } from '@/lib/validations/skill';
import { successResponse, errorResponse } from '@/lib/api/response';
import {
  toDbCategory,
  formatSkillFromDb,
} from '@/lib/utils/skillMappers';
import { Prisma } from '@prisma/client';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const category = searchParams.get('category');
    const search = searchParams.get('search')?.trim();

    // 動態建構 Prisma Where 條件
    const where: Prisma.SkillWhereInput = {};

    if (category && category !== 'All') {
      where.category = toDbCategory(category);
    }

    if (search) {
      where.OR = [
        { title: { contains: search, mode: 'insensitive' } },
        { tags: { has: search } },
      ];
    }

    // 執行 DB 查詢
    const dbSkills = await prisma.skill.findMany({
      where,
      orderBy: { createdAt: 'desc' },
    });

    const formattedSkills = dbSkills.map(formatSkillFromDb);

    return successResponse(formattedSkills, 200, { total: formattedSkills.length });
  } catch (error) {
    console.error('[GET /api/skills Error]:', error);
    return errorResponse('無法取得技能列表,請檢查資料庫連線', 500);
  }
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    // Zod 運行時嚴格驗證
    const validationResult = createSkillSchema.safeParse(body);

    if (!validationResult.success) {
      return errorResponse(
        '欄位驗證失敗,請檢查輸入內容',
        400,
        validationResult.error.flatten().fieldErrors
      );
    }

    const validData = validationResult.data;

    // Prisma 寫入 DB
    const newDbSkill = await prisma.skill.create({
      data: {
        title: validData.title,
        category: toDbCategory(validData.category),
        proficiency: validData.proficiency,
        tags: validData.tags,
        status: validData.status ? toDbStatus(validData.status) : 'LEARNING',
        notesCount: validData.notesCount ?? 0,
      },
    });

    return successResponse(formatSkillFromDb(newDbSkill), 201);
  } catch (error) {
    console.error('[POST /api/skills Error]:', error);
    return errorResponse('伺服器處理請求時發生錯誤', 500);
  }
}

4.實戰步驟3:新增單一技能動態路由PATCH&DELETE(app/api/skills/[id]/route.ts)
接下來,我們需要實作單一資源的操作:更新熟練度(PATCH)與刪除技能(DELETE)。

開啟Cursor,對著@app/api/skills/[id]/route.ts發送Prompt:

@lib/prisma.ts
@lib/validations/skill.ts
@lib/api/response.ts
@lib/utils/skillMappers.ts

請幫我在`app/api/skills/[id]/route.ts`實作動態路由的`PATCH`與`DELETE`Handlers。

需求說明:
1.`PATCH`端點(更新技能/熟練度):
-從`params.id`取得SkillID。
-先使用`prisma.skill.findUnique()`檢查資料是否存在,若不存在回傳`404NotFound`。
-使用`updateSkillSchema.safeParse(body)`進行PartialValidation。
-執行`prisma.skill.update()`,更新DB資料並回傳最新物件。

2.`DELETE`端點(刪除技能):
-檢查技能是否存在(404)。
-執行`prisma.skill.delete({where:{id}})`。
-回傳`200OK`訊息:`{message:"技能已成功刪除"}`。

AI產出的app/api/skills/[id]/route.ts:

import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { updateSkillSchema } from '@/lib/validations/skill';
import { successResponse, errorResponse } from '@/lib/api/response';
import {
  toDbCategory,
  toDbStatus,
  formatSkillFromDb,
} from '@/lib/utils/skillMappers';

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const { id } = params;

    // 檢查資源是否存在
    const existingSkill = await prisma.skill.findUnique({ where: { id } });
    if (!existingSkill) {
      return errorResponse('找不到指定的技能項目', 404);
    }

    const body = await request.json();
    const validationResult = updateSkillSchema.safeParse(body);

    if (!validationResult.success) {
      return errorResponse(
        '更新資料格式不正確',
        400,
        validationResult.error.flatten().fieldErrors
      );
    }

    const data = validationResult.data;

    // 組裝更新 Data (僅更新有傳入的欄位)
    const updatedDbSkill = await prisma.skill.update({
      where: { id },
      data: {
        ...(data.title && { title: data.title }),
        ...(data.category && { category: toDbCategory(data.category) }),
        ...(data.proficiency !== undefined && { proficiency: data.proficiency }),
        ...(data.tags && { tags: data.tags }),
        ...(data.status && { status: toDbStatus(data.status) }),
        ...(data.notesCount !== undefined && { notesCount: data.notesCount }),
        lastPracticedAt: new Date(),
      },
    });

    return successResponse(formatSkillFromDb(updatedDbSkill));
  } catch (error) {
    console.error(`[PATCH /api/skills/${params.id} Error]:`, error);
    return errorResponse('更新技能失敗', 500);
  }
}

export async function DELETE(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const { id } = params;

    const existingSkill = await prisma.skill.findUnique({ where: { id } });
    if (!existingSkill) {
      return errorResponse('找不到指定的技能項目', 404);
    }

    await prisma.skill.delete({ where: { id } });

    return successResponse({ message: '技能已成功刪除' }, 200);
  } catch (error) {
    console.error(`[DELETE /api/skills/${params.id} Error]:`, error);
    return errorResponse('刪除技能失敗', 500);
  }
}

今天我們成功替VibePulse完成了後端資料庫層級的最後拼圖:

建立了解耦Enum的Mapper工具,確保API層與DB結構清晰獨立。

將/api/skills的GET與POST正式升級為Prisma原生資料庫查詢與寫入。

建立了動態路由/api/skills/[id],實現了強健的PATCH(更新)與DELETE(刪除)邏輯。


上一篇
Day 15|資料庫建模Prisma ORM與PostgreSQL Schema設計
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言