从业务表到用户数据 API
用个人收藏链接,实现数据库迁移、分层写入、用户隔离和游标分页读取。
这一篇为工作台建立“收藏链接”的数据接口。登录用户可以提交标题和网址,再分批读取自己的收藏。接口不接收用户 ID,也不会读取其他用户的数据。
完成后得到两个接口:
| 方法与路径 | 用途 |
|---|---|
POST /api/saved-links | 创建一条收藏 |
GET /api/saved-links?beforeId=123 | 读取一页收藏,首次请求省略 beforeId |
本篇完成的是可独立验证的数据接口,工作台列表和表单需要后续通过这些接口接入。编辑、删除、自动抓取网页信息不在本次实现中。
一、确定文件和数据流
新增文件如下:
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,列表包含刚创建的记录。这些命令用于接口验收,正式页面应使用项目现有请求封装,并通过语言资源显示成功、校验失败和网络错误等文案。
再检查以下边界:
- 空标题、无效网址、
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,先确认迁移执行在当前开发服务器使用的本地数据库上。
下一步可以给工作台增加列表和表单,或者按付费权益教程为这两个接口增加购买要求。新增编辑、删除接口时,也要让数据查询同时限定当前用户和记录 ID。