从业务表到用户数据 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。