從業務資料表到使用者資料 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。