iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0
佛心分享-SideProject30

Road To Full-Stack:前端轉全端的 Instagram 挑戰系列 第 4

Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 4

  • 分享至 

  • xImage
  •  

本日目標

導入「使用者列表」的游標分頁(Keyset Pagination),統一錯誤碼與 HTTP 狀態碼映射,補上資料庫複合索引與更貼近真實的種子資料,同步調整 API、頁面與測試。

功能概覽

  • 分頁:新增以 created_at DESC, id DESC 為序的游標分頁;壞游標回傳一致錯誤碼
  • 錯誤集中管理:以 ErrorCode 搭配 ErrorConfig 決定訊息與狀態碼
  • API:GET /api/users 支援 limitcursor 查詢參數;POST /api/users 使用集中錯誤
  • 頁面:新增 Client Component UsersList,支援按鈕載入更多
  • 資料庫:補上 (created_at, id) 複合索引,配合 keyset 分頁
  • 測試:新增 GET /api/users handler 覆蓋、服務層分頁與壞游標測試
  • 工具:加入 dayjs 處理時間;ESLint 新增 simple-import-sort

套件與腳本

這次新增與調整:

  • 依賴:dayjs
  • 開發依賴:eslint-plugin-simple-import-sort
  • Drizzle 指令維持(另補 studio/push):
{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "tsx scripts/db/migrate.ts",
    "db:seed": "tsx scripts/db/seed.ts",
    "db:studio": "drizzle-kit studio",
    "db:push": "drizzle-kit push"
  }
}

資料庫 Schema 與 Migrations

為配合 keyset 分頁,將 users 表建立複合索引:

src/lib/db/schema.ts

import { index, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';

export const users = pgTable(
  'users',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    email: varchar('email', { length: 255 }).notNull().unique(),
    password: varchar('password', { length: 255 }).notNull(),
    name: varchar('name', { length: 255 }).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => [index('users_created_at_id_idx').on(table.createdAt, table.id)]
);

服務層與 Repository

集中錯誤碼與映射:

src/server/errors.ts

export enum ErrorCode {
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  EMAIL_TAKEN = 'EMAIL_TAKEN',
  INTERNAL_ERROR = 'INTERNAL_ERROR',
  BAD_CURSOR = 'BAD_CURSOR',
}

export const ErrorConfig = {
  [ErrorCode.VALIDATION_ERROR]: { message: '驗證錯誤', statusCode: 400 },
  [ErrorCode.EMAIL_TAKEN]: { message: 'Email 已被註冊', statusCode: 409 },
  [ErrorCode.INTERNAL_ERROR]: { message: '內部錯誤', statusCode: 500 },
  [ErrorCode.BAD_CURSOR]: { message: '游標格式錯誤', statusCode: 400 },
} as const;

Repository 以 keyset 分頁(相同 created_atid DESC 作為 tie‑breaker):

src/server/repos/usersRepo.ts

export async function listByCreatedAt(limit = 20, cursor?: UsersCursor | null) {
  const pageSize = Math.max(1, limit);
  let query = db.select().from(users);

  if (cursor) {
    query = query.where(
      or(
        lt(users.createdAt, cursor.createdAt),
        and(eq(users.createdAt, cursor.createdAt), lt(users.id, cursor.id))
      )
    );
  }

  const rows = await query.orderBy(desc(users.createdAt), desc(users.id)).limit(pageSize + 1);
  const hasMore = rows.length > pageSize;
  const items = hasMore ? rows.slice(0, pageSize) : rows;
  const nextCursor = hasMore
    ? { createdAt: items[items.length - 1]!.createdAt, id: items[items.length - 1]!.id }
    : null;
  return { items, nextCursor };
}

服務層 getUsers 會 clamp limit,並提供 Base64 JSON 游標的 encode/decode 與壞游標錯誤:

src/server/users.ts

function encodeCursor(cursor: UsersCursor): string {
  return Buffer.from(
    JSON.stringify({ createdAt: cursor.createdAt.toISOString(), id: cursor.id }),
    'utf8'
  ).toString('base64');
}

function decodeCursor(cursor: string): UsersCursor | null {
  try {
    const raw = Buffer.from(cursor, 'base64').toString('utf8');
    const safe = CursorPayloadSchema.safeParse(JSON.parse(raw));
    if (!safe.success) return null;
    const createdAt = dayjs(safe.data.createdAt).toDate();
    return dayjs(createdAt).isValid() ? { createdAt, id: safe.data.id } : null;
  } catch {
    return null;
  }
}

API 路由

統一以集中錯誤映射回傳狀態碼;GET 支援 limitcursor 查詢參數:

src/app/api/users/route.ts

export async function GET(req: NextRequest) {
  try {
    const limitParam = req.nextUrl.searchParams.get('limit');
    const cursor = req.nextUrl.searchParams.get('cursor');
    const limit = limitParam !== null ? Number(limitParam) : undefined;
    const result = await getUsers(limit, cursor);
    const status = result.ok ? 200 : ErrorConfig[result.code].statusCode;
    return NextResponse.json(result, { status });
  } catch {
    return NextResponse.json(
      {
        ok: false,
        code: ErrorCode.INTERNAL_ERROR,
        message: ErrorConfig[ErrorCode.INTERNAL_ERROR].message,
      },
      { status: 500 }
    );
  }
}

首頁與元件

以 Client Component 管理「載入更多」狀態與錯誤展示:

src/app/_components/users-list.tsx

const handleLoadMore = async () => {
  if (!nextCursor || loading) return;
  setLoading(true);
  setError(null);
  try {
    const res = await fetch(`/api/users?cursor=${encodeURIComponent(nextCursor)}`);
    const json = (await res.json()) as ApiResult<PageResult<UserItem>>;
    if (!res.ok || !json.ok) throw new Error(!res.ok ? '載入失敗' : json.message);
    setItems((prev) => [...prev, ...json.data.items]);
    setNextCursor(json.data.nextCursor);
  } catch (err) {
    setError(err instanceof Error ? err.message : '載入時發生錯誤');
  } finally {
    setLoading(false);
  }
};

測試

  • 新增 tests/api.users.get.test.ts 驗證 limit/cursor 傳遞與壞游標 400
  • 擴充 tests/service.users.test.ts
    • 三頁分頁行為(10/10/5)與最後一頁 nextCursor = null
    • 相同 created_atid DESC 作 tie‑breaker
    • 壞游標回傳 BAD_CURSOR
  • 更新 tests/api.users.post.test.tsservice 測試改用 ErrorCodedayjs

執行:

npm run db:migrate
npm run db:seed
npm run test        # 一次跑完
npm run test:watch  # 監看模式

種子資料

bcrypt 預先雜湊密碼,並使用 dayjs 生成人性化的 createdAt,便於觀察分頁:

scripts/db/seed.ts

const passwordHash = await bcrypt.hash('password123', 10);
const now = dayjs();
const seedUsers: NewUser[] = baseUsers.map((user, index) => {
  const createdAt = now.subtract(index, 'minute').toDate();
  return { ...user, password: passwordHash, createdAt, updatedAt: createdAt };
});
await db.insert(users).values(seedUsers);

心得

本來想推進更多功能,但從頭看AI產出的內容發現蠻多要調整的,所以今天只有完善API相關內容,明天預計把登入功能完成。


上一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 3
下一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 5
系列文
Road To Full-Stack:前端轉全端的 Instagram 挑戰5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言