Skip to content

操作指南:书签 API

FT 参考:FT295(NENE2-FT/bookmarklog)——书签管理:UNIQUE(user_id, item_id) 防止重复书签,带可选过滤的集合分组,用户范围访问(IDOR 防护),重复时返回 409,22 个测试 / 64 个断言全部通过。

本指南展示如何构建一个书签 API,让用户可以将条目保存到命名集合中,具有去重和用户范围访问控制。

数据库结构

sql
CREATE TABLE bookmarks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    item_id    INTEGER NOT NULL,
    collection TEXT    NOT NULL DEFAULT 'default',
    created_at TEXT    NOT NULL,
    UNIQUE (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
);

UNIQUE(user_id, item_id) 确保每个用户只能为每个条目添加一次书签。collection 字段将书签分组到命名列表中(默认:'default')。

端点

方法路径描述
POST/users/{userId}/bookmarks添加书签
DELETE/users/{userId}/bookmarks/{itemId}删除书签
GET/users/{userId}/bookmarks列出书签(可按集合过滤)
GET/users/{userId}/bookmarks/count统计书签数量
GET/users/{userId}/bookmarks/{itemId}获取特定书签

路由注册顺序

/users/{userId}/bookmarks/count 必须在 /users/{userId}/bookmarks/{itemId} 之前注册,以防止 count 被捕获为 {itemId}

php
$router->get('/users/{userId}/bookmarks', $this->listBookmarks(...));
$router->get('/users/{userId}/bookmarks/count', $this->countBookmarks(...));  // 静态路由在动态路由之前
$router->get('/users/{userId}/bookmarks/{itemId}', $this->getBookmark(...));

添加书签

php
private function addBookmark(ServerRequestInterface $request): ResponseInterface
{
    $params = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $userId = isset($params['userId']) && is_numeric($params['userId']) ? (int) $params['userId'] : 0;

    if ($userId <= 0 || !$this->repo->findUserById($userId)) {
        return $this->responseFactory->create(['error' => 'user not found'], 404);
    }

    $body       = JsonRequestBodyParser::parse($request);
    $itemId     = isset($body['item_id']) && is_int($body['item_id']) ? $body['item_id'] : 0;
    $collection = isset($body['collection']) && is_string($body['collection'])
        ? trim($body['collection']) : 'default';

    if ($itemId <= 0 || !$this->repo->findItemById($itemId)) {
        return $this->responseFactory->create(['error' => 'item not found'], 404);
    }

    if ($collection === '') {
        $collection = 'default';  // 空集合字符串 → 回退到 'default'
    }

    $now      = date('Y-m-d H:i:s');
    $bookmark = $this->repo->add($userId, $itemId, $collection, $now);
    return $this->responseFactory->create($bookmark->toArray(), 201);
}

item_id 需要 is_int()——JSON 字符串 "5" 会被拒绝。数据库中的 UNIQUE 约束捕获竞争条件;仓库应捕获约束违规并返回 409。

列表的集合过滤

php
$query      = $request->getQueryParams();
$collection = isset($query['collection']) && is_string($query['collection']) && $query['collection'] !== ''
    ? $query['collection'] : null;

$items = $this->repo->listByUser($userId, $collection);

不带 ?collection= 时返回所有书签。带 ?collection=favorites 时只返回该集合。空的集合查询参数被视为"无过滤"。

用户范围——IDOR 防护

每个端点在返回数据之前都会验证 userId 是否存在于数据库中:

php
if ($userId <= 0 || !$this->repo->findUserById($userId)) {
    return $this->responseFactory->create(['error' => 'user not found'], 404);
}

以不同用户身份请求 /users/999/bookmarks 返回 404(而非其他用户的书签)。所有查询都以路径中的 userId 为范围。


反模式

反模式风险
UNIQUE(user_id, item_id)用户多次收藏同一条目;产生令人困惑的重复
重复书签时返回 200客户端无法区分"已添加"和"已存在";应使用 409
从请求体接受字符串类型的 item_idJSON 类型混淆:"5"5;使用 is_int()
/count 之前注册 /{itemId}GET /users/1/bookmarks/count 解析为 itemId = "count"(错误的处理器)
不检查用户是否存在不存在的 userId 返回空列表而非 404
查询中无用户范围限定用户 A 看到用户 B 的书签(IDOR)
无集合默认值缺少 collection 字段导致崩溃或在数据库中留下 NULL

基于 MIT 许可证发布。