Skip to content

操作指南:等待列表系统

FT 参考:FT287(NENE2-FT/waitlistlog)— 等待列表系统:UNIQUE(user_id) 单条目约束、waiting→approved/declined 状态机、isTerminal() 守卫、/waitlist/me 在 /{id} 之前注册以防止路由捕获、X-Admin-Key 认证、队列位置跟踪,39 tests / 98 assertions 全部 PASS。

本指南展示如何构建等待列表系统,用户加入队列,管理员批准或拒绝条目。

数据库结构

sql
CREATE TABLE IF NOT EXISTS waitlist_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL UNIQUE,   -- 每用户一条条目
    status     TEXT    NOT NULL DEFAULT 'waiting',  -- waiting | approved | declined
    note       TEXT,                               -- 可选用户备注(最多 500 字符)
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

user_id UNIQUE 在 DB 层强制每用户一条条目——无需应用层检查竞争条件。

端点

方法路径认证说明
POST/waitlistX-User-Id加入等待列表
GET/waitlist/meX-User-Id获取自己的状态 + 位置
DELETE/waitlist/meX-User-Id离开等待列表
GET/waitlistX-Admin-Key管理员:列出全部条目
POST/waitlist/{id}/approveX-Admin-Key管理员:批准条目
POST/waitlist/{id}/declineX-Admin-Key管理员:拒绝条目

路由注册顺序

/waitlist/me 必须在 /waitlist/{id} 之前注册,以防止路径参数捕获字面字符串 "me"

php
// 正确:静态路径在动态路径之前
$this->router->get('/waitlist/me', $this->handleMe(...));
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));

// 错误:{id} 会捕获 "me"
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));
$this->router->get('/waitlist/me', $this->handleMe(...));  // 永远不会被匹配到

状态生命周期

waiting ──────→ approved(终止)
       └──────→ declined(终止)

一旦批准或拒绝,条目就不能转换到其他状态。isTerminal() 方法守卫这一点:

php
enum WaitlistStatus: string
{
    case Waiting  = 'waiting';
    case Approved = 'approved';
    case Declined = 'declined';

    public function isTerminal(): bool
    {
        return $this !== self::Waiting;
    }
}

重复加入时返回 409

php
$entry = $this->repository->join($userId, $note);

if ($entry === null) {
    return $this->responseFactory->create(['error' => 'Already on the waitlist.'], 409);
}

user_id 已存在时,repository 返回 null(从 DatabaseConstraintException 捕获)。响应为 409 Conflict。

位置跟踪

php
$position = $this->repository->positionOf($entry);

// positionOf() 统计 status='waiting' 且 id <= $entry->id 的条目数
// SELECT COUNT(*) FROM waitlist_entries WHERE status = 'waiting' AND id <= ?

位置是 waiting 队列中基于 1 的排名。已批准/拒绝的条目不计入。这给用户提供了有意义的排队位置。

管理员状态转换(使用 match)

php
private function handleTransition(int $id, WaitlistStatus $newStatus): ResponseInterface
{
    $result = $this->repository->transition($id, $newStatus);

    return match ($result) {
        'ok'               => $this->responseFactory->create(['status' => $newStatus->value]),
        'not_found'        => $this->responseFactory->create(['error' => 'Entry not found.'], 404),
        'already_terminal' => $this->responseFactory->create(['error' => 'Entry is already approved or declined.'], 409),
        default            => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
    };
}

match 是穷举的——default 分支捕获 repository 的任何意外返回值。

离开(仅限等待中)

php
return match ($result) {
    'removed'     => $this->responseFactory->create(['removed' => true], 200),
    'not_found'   => $this->responseFactory->create(['error' => 'Not on the waitlist.'], 404),
    'not_waiting' => $this->responseFactory->create(['error' => 'Cannot leave — status is no longer waiting.'], 409),
    default       => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
};

一旦批准或拒绝,用户就不能离开——其决定已被记录。这防止了对系统的操控(批准后离开以避免跟踪)。

管理员认证

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false;  // fail-closed:未配置密钥 → 无管理员访问
    }
    return hash_equals($this->adminKey, $request->getHeaderLine('X-Admin-Key'));
}

hash_equals() 防止时序攻击。空管理员密钥始终返回 false(fail-closed)。

备注验证

php
private const int MAX_NOTE_LEN = 500;

private function resolveNote(mixed $raw): ?string
{
    if (!is_string($raw) || trim($raw) === '') {
        return null;
    }
    return mb_strlen($raw) > self::MAX_NOTE_LEN ? mb_substr($raw, 0, self::MAX_NOTE_LEN) : $raw;
}

备注是可选的(缺失/空时为 null),最多 500 字符,超长时截断(而非拒绝)。


不应做的事

反模式风险
UNIQUE(user_id) 约束并发加入创建重复条目;竞争条件
/me 之前注册 /{id}/waitlist/me 变得不可达——被 {id} 捕获 "me"
允许从终止状态转换已批准条目在授权后被拒绝;状态机被破坏
允许从终止状态离开已批准用户离开;访问授权变成孤儿
基于 id ASC 统计全部条目计算位置计入了已批准/拒绝的用户;位置数字具有误导性
将管理员密钥存入 DB密钥轮换需要 DB 更新;应使用环境变量
使用 == 而非 hash_equals() 比较管理员密钥时序攻击逐字符揭示密钥
无管理员 fail-closed环境变量中空密钥允许未认证的管理员访问
备注超长时拒绝用户体验:对备注等软性元数据进行截断比拒绝更友好

基于 MIT 许可证发布。