Skip to content

使用 NENE2 构建限时特卖系统

本指南介绍如何构建时间限制、数量约束的限时特卖系统,用户可以在特卖窗口内以折扣价购买商品。

字段试验:FT140 NENE2 版本:^1.5 涵盖主题:时间窗口校验,使用 COUNT(*) 进行库存计数,UNIQUE 约束实现每用户一次购买,match 表达式处理状态,破解者思维攻击测试


我们要构建的内容

  • POST /products — 创建商品
  • POST /sales — 创建限时特卖(product_id、price、quantity、starts_at、ends_at)
  • GET /sales/{saleId} — 查看特卖详情(含剩余数量和状态)
  • POST /sales/{saleId}/purchase — 在活跃窗口内购买(每用户一次)
  • GET /sales/{saleId}/purchases — 列出所有购买者

数据库结构

sql
CREATE TABLE flash_sales (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id INTEGER NOT NULL,
    price      INTEGER NOT NULL,
    quantity   INTEGER NOT NULL,
    starts_at  TEXT    NOT NULL,
    ends_at    TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    CHECK (quantity > 0),
    CHECK (price >= 0),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

CREATE TABLE purchases (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    sale_id      INTEGER NOT NULL,
    user_id      INTEGER NOT NULL,
    purchased_at TEXT    NOT NULL,
    UNIQUE (sale_id, user_id),
    FOREIGN KEY (sale_id) REFERENCES flash_sales(id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

UNIQUE (sale_id, user_id) 在 DB 层面防止用户对同一特卖重复购买,即使在并发请求下也有效。


时间窗口校验

php
$now = date('c');

if ($now < $sale['starts_at']) {
    return $this->responseFactory->create(['error' => 'sale has not started yet'], 422);
}

if ($now > $sale['ends_at']) {
    return $this->responseFactory->create(['error' => 'sale has ended'], 422);
}

starts_at / ends_at 存储为 ISO 8601 字符串。字符串比较对 ISO 8601 有效,因为该格式按词典顺序排序。


使用 COUNT(*) 进行库存计数

不维护可变的 remaining 列,而是对实际购买数量计数:

php
public function countPurchases(int $saleId): int
{
    $row = $this->executor->fetchOne(
        'SELECT COUNT(*) as cnt FROM purchases WHERE sale_id = ?',
        [$saleId],
    );

    return isset($row['cnt']) ? (int) $row['cnt'] : 0;
}

然后检查:

php
$purchased = $this->repository->countPurchases($saleId);

if ($purchased >= $sale['quantity']) {
    return $this->responseFactory->create(['error' => 'sold out'], 422);
}

remaining 在读取时派生:$sale['quantity'] - $purchased。使用 max(0, $remaining) 限制以避免负数显示。


每用户一次购买——UNIQUE 约束

UNIQUE (sale_id, user_id) 在 DB 层面防止重复。DatabaseConstraintException 映射为 409:

php
public function purchase(int $saleId, int $userId, string $now): bool
{
    try {
        $this->executor->execute(
            'INSERT INTO purchases (sale_id, user_id, purchased_at) VALUES (?, ?, ?)',
            [$saleId, $userId, $now],
        );

        return true;
    } catch (DatabaseConstraintException) {
        return false;
    }
}

purchase() 返回 false 时,处理程序返回 409。


使用 match 表达式处理特卖状态

php
$status = match (true) {
    $now < $sale['starts_at'] => 'upcoming',
    $now > $sale['ends_at']   => 'ended',
    default                   => 'active',
};

三种状态:upcoming(即将开始)、active(进行中)、ended(已结束)。match 表达式是穷举的,因为 default 覆盖了所有其他情况。


破解者攻击测试结果(FT140)

ID攻击期望结果
ATK-01商品名称 SQL 注入201(原样存储)通过
ATK-02无 X-User-Id 购买400通过
ATK-03非数字 X-User-Id非 201通过
ATK-04URL 中的负数 saleId非 201通过
ATK-05特卖开始前购买422通过
ATK-06特卖结束后购买422通过
ATK-07同一特卖重复购买第二次 409通过
ATK-08耗尽库存后购买422 售罄通过
ATK-09创建 quantity=0 特卖422通过
ATK-10创建负价格特卖422通过
ATK-11以不存在的用户购买404通过
ATK-12ends_at 早于 starts_at422通过

12 个攻击测试全部通过。


常见陷阱

陷阱解决方案
可变 remaining 列在并发下失准purchases 表计数,读取时派生 remaining
API 允许 quantity=0在处理程序中校验 $quantity > 0;schema 中也添加 CHECK (quantity > 0)
负价格漏网校验 $price >= 0;schema 中也添加 CHECK (price >= 0)
用户对同一特卖购买两次UNIQUE (sale_id, user_id) + DatabaseConstraintException → 409
非 ISO 字符串的时间比较使用 ISO 8601(如 date('c'))——词典顺序比较正确
ends_atstarts_at 倒置在 INSERT 前校验 $starts_at < $ends_at

基于 MIT 许可证发布。