從業務資料表到使用者資料 API

以個人收藏連結為例,實作資料庫遷移、分層寫入、使用者資料隔離和游標分頁讀取。

這一篇要為工作區建立「收藏連結」的資料 API。已登入的使用者可以提交標題和網址,再分批讀取自己的收藏。API 不接受使用者 ID,也不會讀取其他使用者的資料。

完成後會有兩個 API:

方法與路徑用途
POST /api/saved-links建立一筆收藏
GET /api/saved-links?beforeId=123讀取一頁收藏,第一次請求時省略 beforeId

本篇完成的是可以獨立驗證的資料 API,工作區的清單和表單需要後續透過這些 API 串接。本次實作不包含編輯、刪除和自動擷取網頁資訊。

一、確定檔案和資料流程

新增的檔案如下:

schema/migrations/0001-db-saved-links.sql
src/core/db/saved-link/index.ts
src/core/repositories/saved-link/index.ts
src/core/services/saved-link/types.ts
src/core/services/saved-link/service.ts
src/api/saved-links/index.ts

呼叫順序是 API → Service → Repository → DB。SQL 留在 DB 層,Repository 將儲存欄位轉換為業務物件,Service 負責建立規則和分頁結果,API 負責身分驗證、參數驗證和回應格式。

二、透過遷移建立資料表

新增 schema/migrations/0001-db-saved-links.sql。如果已有編號為 0001 的遷移,請使用下一個可用編號,檔名仍保留 -db-,讓它符合主資料庫的遷移規則。

CREATE TABLE saved_link (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    title TEXT NOT NULL,
    url TEXT NOT NULL,
    created_at INTEGER NOT NULL
);

created_at 儲存 Unix 毫秒,user_id 對應目前的帳號 ID。這個範例不新增外部索引鍵,也不變更既有的帳號刪除流程。如果產品允許刪除帳號,上線前需要將收藏資料的清理作業加入該流程。

執行本機遷移:

npm run db:migrate:local

範本已有遷移機制。schema/db-init.sql 是主資料庫的基準結構,後續變更放進 schema/migrations。這裡不要同時將相同的建表陳述式加入基準檔案,否則新環境執行基準檔案後再套用遷移時,會重複建立同一張資料表。

驗證完成後,遠端環境可執行 npm run db:migrate:remote,既有的 deploy:update 流程也會執行遷移。新增業務資料表不需要重設資料庫。

三、定義業務物件

新增 src/core/services/saved-link/types.ts:

export type SavedLink = {
    id: number;
    title: string;
    url: string;
    createdAt: Date;
};

export type CreateSavedLinkInput = {
    title: string;
    url: string;
};

業務物件使用 createdAt 和 Date,不將資料庫中含底線的欄位名稱帶到其他層。使用者 ID 作為操作的擁有者參數獨立傳遞,不放進瀏覽器可以填寫的建立參數中。

四、實作 DB 層

新增 src/core/db/saved-link/index.ts:

import type { WorkerCtx } from '@/ctx';

export type SavedLinkRow = {
    id: number;
    title: string;
    url: string;
    created_at: number;
};

export default class DBSavedLinkDao {
    private constructor(private readonly db: D1Database) {}

    static withCtx(ctx: WorkerCtx): DBSavedLinkDao {
        return new DBSavedLinkDao(ctx.env.DB);
    }

    async insert(input: {
        userId: number;
        title: string;
        url: string;
        createdAt: number;
    }): Promise<SavedLinkRow> {
        const row = await this.db.prepare(`
            INSERT INTO saved_link (user_id, title, url, created_at)
            VALUES (?, ?, ?, ?)
            RETURNING id, title, url, created_at
        `).bind(
            input.userId, input.title, input.url, input.createdAt,
        ).first<SavedLinkRow>();

        if (!row) throw new Error('Saved link insert returned no row');
        return row;
    }

    async listForUser(
        userId: number,
        limit: number,
        beforeId?: number,
    ): Promise<SavedLinkRow[]> {
        const statement = beforeId === undefined
            ? this.db.prepare(`
                SELECT id, title, url, created_at FROM saved_link
                WHERE user_id = ? ORDER BY id DESC LIMIT ?
            `).bind(userId, limit)
            : this.db.prepare(`
                SELECT id, title, url, created_at FROM saved_link
                WHERE user_id = ? AND id < ? ORDER BY id DESC LIMIT ?
            `).bind(userId, beforeId, limit);

        const result = await statement.all<SavedLinkRow>();
        return result.results;
    }
}

兩條讀取路徑都包含 user_id = ?。即使使用者手動修改 beforeId,也只能改變自己資料的分頁位置。SQL 參數透過 bind 傳入,不將輸入值串接到查詢字串中。

清單依自動遞增的 ID 由大到小讀取,新增資料不會讓下一頁的位置往後移。這裡先使用主索引鍵,不預先新增其他索引,等資料量成長後,再依實際查詢頻率和 D1 讀取量評估。

五、Repository 轉換資料

新增 src/core/repositories/saved-link/index.ts:

import type { WorkerCtx } from '@/ctx';
import DBSavedLinkDao, { type SavedLinkRow } from '@/core/db/saved-link';
import type {
    CreateSavedLinkInput,
    SavedLink,
} from '@/core/services/saved-link/types';

function toSavedLink(row: SavedLinkRow): SavedLink {
    return {
        id: row.id,
        title: row.title,
        url: row.url,
        createdAt: new Date(row.created_at),
    };
}

export const savedLinkRepository = {
    async create(
        ctx: WorkerCtx,
        userId: number,
        input: CreateSavedLinkInput,
        createdAt: Date,
    ): Promise<SavedLink> {
        const row = await DBSavedLinkDao.withCtx(ctx).insert({
            userId,
            title: input.title,
            url: input.url,
            createdAt: createdAt.getTime(),
        });
        return toSavedLink(row);
    },

    async listForUser(
        ctx: WorkerCtx,
        userId: number,
        limit: number,
        beforeId?: number,
    ): Promise<SavedLink[]> {
        const rows = await DBSavedLinkDao.withCtx(ctx).listForUser(
            userId, limit, beforeId,
        );
        return rows.map(toSavedLink);
    },
};

這裡透過 import type 匯入業務型別,只用來約束對應轉換的結果,不在 Repository 中呼叫 Service。新增的 API 不應直接匯入這個 Repository。

六、Service 組織業務操作

新增 src/core/services/saved-link/service.ts:

import type { WorkerCtx } from '@/ctx';
import { savedLinkRepository } from '@/core/repositories/saved-link';
import type { CreateSavedLinkInput } from './types';

const PAGE_SIZE = 20;

export const savedLinkService = {
    async create(
        ctx: WorkerCtx,
        userId: number,
        input: CreateSavedLinkInput,
    ) {
        return savedLinkRepository.create(ctx, userId, input, new Date());
    },

    async listForUser(ctx: WorkerCtx, userId: number, beforeId?: number) {
        const rows = await savedLinkRepository.listForUser(
            ctx, userId, PAGE_SIZE + 1, beforeId,
        );
        const items = rows.slice(0, PAGE_SIZE);
        const nextCursor = rows.length > PAGE_SIZE
            ? items[items.length - 1].id
            : null;

        return { items, nextCursor };
    },
};

每次多取一筆,只用來判斷是否還有下一頁。回應最多回傳 20 筆,nextCursor 為 null 時結束,否則將它作為下一次請求的 beforeId。

建立時間由伺服器產生。同一個網址可以儲存多次,這是本例的業務規則。建立操作也沒有請求去重機制,前端應在提交期間停用按鈕,遇到網路逾時先重新整理清單以確認結果,不要無條件自動重試。

七、串接 API

新增 src/api/saved-links/index.ts:

import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import { resolveFetchWorkerCtx } from '@/ctx';
import { gResultCode } from '@/errors';
import type { Variables } from '@/types';
import { authenticatedGuard } from '@/core/services/auth/guards/authenticated';
import { savedLinkService } from '@/core/services/saved-link/service';
import type { SavedLink } from '@/core/services/saved-link/types';

const savedLinksApi = new Hono<{
    Bindings: CloudflareBindings;
    Variables: Variables;
}>();

const createSchema = z.object({
    title: z.string().trim().min(1).max(120),
    url: z.string().trim().max(2048).url().refine((value) => {
        if (!URL.canParse(value)) return false;
        const protocol = new URL(value).protocol;
        return protocol === 'http:' || protocol === 'https:';
    }),
}).strict();

const querySchema = z.object({
    beforeId: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
});

type SavedLinkDto = {
    id: number;
    title: string;
    url: string;
    createdAt: string;
};

function toDto(link: SavedLink): SavedLinkDto {
    return {
        id: link.id,
        title: link.title,
        url: link.url,
        createdAt: link.createdAt.toISOString(),
    };
}

savedLinksApi.post('/', bodyLimit({ maxSize: 16 * 1024 }), async (c) => {
    const guard = authenticatedGuard(c);
    if (!guard.success) {
        return c.json(guard, guard.code === gResultCode.authLoginRequired ? 401 : 403);
    }

    const body: unknown = await c.req.json().catch(() => null);
    const parsed = createSchema.safeParse(body);
    if (!parsed.success) {
        return c.json({ success: false, code: gResultCode.badParams }, 400);
    }

    const link = await savedLinkService.create(
        resolveFetchWorkerCtx(c), guard.data.authContext.userId, parsed.data,
    );
    return c.json({ success: true, data: toDto(link) }, 201);
});

savedLinksApi.get('/', async (c) => {
    const guard = authenticatedGuard(c);
    if (!guard.success) {
        return c.json(guard, guard.code === gResultCode.authLoginRequired ? 401 : 403);
    }

    const parsed = querySchema.safeParse(c.req.query());
    if (!parsed.success) {
        return c.json({ success: false, code: gResultCode.badParams }, 400);
    }

    const page = await savedLinkService.listForUser(
        resolveFetchWorkerCtx(c), guard.data.authContext.userId, parsed.data.beforeId,
    );
    return c.json({
        success: true,
        data: { items: page.items.map(toDto), nextCursor: page.nextCursor },
    });
});

export default savedLinksApi;

API 將 Date 轉換為 ISO 字串,只回傳明確宣告的欄位。參數錯誤時回傳 400,尚未登入時回傳 401,其他身分驗證要求未滿足時回傳 403。超過請求大小限制時,由 bodyLimit 回傳 413,資料庫例外則交給既有的 API 錯誤處理器。

url 只允許 HTTP 和 HTTPS,本例只儲存網址,不會由伺服器向目標網頁發出請求。日後新增網頁擷取功能時,需要另外設計允許存取的目標和重新導向規則。

在 src/api/routes.ts 中匯入並註冊子路由,註冊位置放在 apiApp 掛載到應用程式之前:

import savedLinksApi from './saved-links';

// 與現有的 apiApp.route(...) 宣告放在一起。
apiApp.route('/saved-links', savedLinksApi);

既有入口會統一掛載 /api 前綴。不要在子路由中再寫一次 /api/saved-links,也不要為了方便偵錯而關閉全域 CSRF 防護。

八、透過瀏覽器驗證

登入本機網站後,在同一個網站的瀏覽器開發人員工具主控台中執行:

const created = await fetch('/api/saved-links', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'same-origin',
    body: JSON.stringify({ title: 'Saavo', url: 'https://saavo.dev' }),
});
console.log(created.status, await created.json());

const firstPage = await fetch('/api/saved-links', {
    credentials: 'same-origin',
}).then((response) => response.json());
console.log(firstPage);

if (firstPage.success && firstPage.data.nextCursor !== null) {
    const nextPage = await fetch(
        `/api/saved-links?beforeId=${firstPage.data.nextCursor}`,
    ).then((response) => response.json());
    console.log(nextPage);
}

建立請求應回傳 201,清單中應包含剛建立的紀錄。這些指令用於 API 驗收,正式頁面應使用專案既有的請求封裝,並透過語言資源顯示成功、驗證失敗和網路錯誤等文案。

接著檢查下列邊界情況:

  • 空白標題、無效網址和 javascript: 網址會回傳 400,資料庫沒有新增紀錄。
  • 請求主體額外提交 userId 時回傳 400,身分不能由瀏覽器指定。
  • 尚未登入的請求回傳 401,不回傳登入頁面的 HTML。
  • 分別用帳號 A、B 建立紀錄,各自只能看到自己的收藏。
  • 建立超過 20 筆資料後,用 nextCursor 繼續讀取,直到它為 null,既有紀錄沒有重複或遺漏。
  • 切換分頁期間新增收藏,新收藏會出現在重新取得的第一頁,後續游標分頁仍沿原本的位置繼續。
  • 再次執行本機遷移時,不會重複建立資料表。

最後執行 npm run lint、npm run typecheck 和 npm run build。如果出現 no such table: saved_link,請先確認遷移是在目前開發伺服器使用的本機資料庫上執行。

下一步可以為工作區新增清單和表單,或依照付費權益教學為這兩個 API 加入購買要求。新增編輯、刪除 API 時,也要讓資料查詢同時限定目前使用者和紀錄 ID。