PAYATHON 2026

如何根据 subscription 或 billing 显示 mySQL 数据

支付老李

结论

不要在用户表中为每场比赛添加一个 True/False 字段。这种设计只适合数据量很小、内容也不会变化的演示系统。比赛或商品一多,就得频繁修改表结构,查询、退款和审计也会变得越来越麻烦。

更合适的设计是把系统分成三个部分:

  1. 数据:篮球比赛及其统计信息。
  2. 商品或套餐:用户实际购买的内容,例如某个赛季、某支球队、某类统计数据或完整数据包。
  3. 访问权益(entitlement):记录用户有权访问哪些商品,以及权益是否有效、何时到期。

用户登录后,PHP 后端根据当前用户的有效权益查询数据。权限必须由服务端判断,不能只靠前端隐藏按钮或图表。

推荐的数据模型

如果用户购买的是某一场比赛,可以采用下面的结构:

CREATE TABLE users (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
);

CREATE TABLE games (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    played_at DATETIME NOT NULL,
    winner VARCHAR(100) NOT NULL,
    loser VARCHAR(100) NOT NULL,
    winner_score INT NOT NULL,
    loser_score INT NOT NULL
);

CREATE TABLE products (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    access_type ENUM('game', 'season', 'team', 'all_data') NOT NULL,
    access_value VARCHAR(100) NULL,
    price DECIMAL(10, 2) NOT NULL
);

CREATE TABLE orders (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    provider VARCHAR(50) NOT NULL,
    provider_order_id VARCHAR(255) NULL,
    status ENUM('pending', 'paid', 'refunded', 'cancelled') NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    paid_at DATETIME NULL,
    UNIQUE KEY uq_provider_order (provider, provider_order_id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE user_entitlements (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    product_id BIGINT UNSIGNED NOT NULL,
    order_id BIGINT UNSIGNED NULL,
    starts_at DATETIME NOT NULL,
    expires_at DATETIME NULL,
    revoked_at DATETIME NULL,
    UNIQUE KEY uq_user_product_order (user_id, product_id, order_id),
    KEY idx_active_entitlement (user_id, product_id, revoked_at, expires_at),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (product_id) REFERENCES products(id),
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

CREATE TABLE product_games (
    product_id BIGINT UNSIGNED NOT NULL,
    game_id BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (product_id, game_id),
    FOREIGN KEY (product_id) REFERENCES products(id),
    FOREIGN KEY (game_id) REFERENCES games(id)
);

各张表的作用如下:

  • orders 保存支付事实和支付状态。
  • user_entitlements 保存当前访问权。
  • product_games 记录一个商品包含哪些比赛。
  • expires_at IS NULL 可以表示永久购买。
  • revoked_at 用于在退款、拒付或人工撤销后收回权限。

这种模型比布尔字段更灵活。一个赛季包可以关联几百场比赛,而用户只需获得一条对应商品的权益,不必为每场比赛修改用户表。

查询用户有权查看的数据

用户登录后,服务端应从 Session 中取得用户 ID,不能相信请求中传入的 user_id。

查询当前用户有权查看的比赛时,可以使用:

SELECT DISTINCT
    g.id,
    g.played_at,
    g.winner,
    g.loser,
    g.winner_score,
    g.loser_score
FROM games AS g
INNER JOIN product_games AS pg
    ON pg.game_id = g.id
INNER JOIN user_entitlements AS ue
    ON ue.product_id = pg.product_id
INNER JOIN orders AS o
    ON o.id = ue.order_id
WHERE ue.user_id = :user_id
  AND o.status = 'paid'
  AND ue.starts_at <= NOW()
  AND (ue.expires_at IS NULL OR ue.expires_at > NOW())
  AND ue.revoked_at IS NULL
ORDER BY g.played_at DESC;

即使用户手动修改 URL、请求参数或前端 JavaScript,这条查询也只会返回他有权访问的数据。

读取某一场指定比赛时,也必须把权限条件写进查询:

SELECT g.*
FROM games AS g
WHERE g.id = :game_id
  AND EXISTS (
      SELECT 1
      FROM product_games AS pg
      INNER JOIN user_entitlements AS ue
          ON ue.product_id = pg.product_id
      INNER JOIN orders AS o
          ON o.id = ue.order_id
      WHERE pg.game_id = g.id
        AND ue.user_id = :user_id
        AND o.status = 'paid'
        AND ue.starts_at <= NOW()
        AND (ue.expires_at IS NULL OR ue.expires_at > NOW())
        AND ue.revoked_at IS NULL
  );

如果查询没有结果,可以返回 404 Not Found,以免向无权访问的用户透露这场比赛是否存在。也可以返回 403 Forbidden,具体取决于接口的信息披露策略。

PHP 服务端示例

下面的示例使用 PDO 和参数绑定:

<?php

declare(strict_types=1);

session_start();

if (!isset($_SESSION['user_id'])) {
    http_response_code(401);
    exit('请先登录');
}

$userId = (int) $_SESSION['user_id'];
$gameId = filter_input(INPUT_GET, 'game_id', FILTER_VALIDATE_INT);

if ($gameId === false || $gameId === null || $gameId < 1) {
    http_response_code(400);
    exit('无效的比赛编号');
}

$pdo = new PDO(
    'mysql:host=localhost;dbname=basketball;charset=utf8mb4',
    'app_user',
    'app_password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

$sql = <<<'SQL'
SELECT g.id,
       g.played_at,
       g.winner,
       g.loser,
       g.winner_score,
       g.loser_score
FROM games AS g
WHERE g.id = :game_id
  AND EXISTS (
      SELECT 1
      FROM product_games AS pg
      INNER JOIN user_entitlements AS ue
          ON ue.product_id = pg.product_id
      INNER JOIN orders AS o
          ON o.id = ue.order_id
      WHERE pg.game_id = g.id
        AND ue.user_id = :user_id
        AND o.status = 'paid'
        AND ue.starts_at <= NOW()
        AND (ue.expires_at IS NULL OR ue.expires_at > NOW())
        AND ue.revoked_at IS NULL
  )
SQL;

$stmt = $pdo->prepare($sql);
$stmt->execute([
    'game_id' => $gameId,
    'user_id' => $userId,
]);

$game = $stmt->fetch();

if ($game === false) {
    http_response_code(404);
    exit('数据不存在或无权访问');
}

header('Content-Type: application/json; charset=utf-8');
echo json_encode(
    $game,
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);

重点不在于图表组件怎样显示数据,而在于图表的数据源只能接收后端授权后的结果。不能先把全部比赛数据发到浏览器,再用 JavaScript 隐藏用户尚未购买的部分,因为用户仍然可以从接口响应或开发者工具中获取完整数据。

一次性购买与订阅的区别

一次性购买

一次性购买通常会生成长期权益:

starts_at = 支付成功时间
expires_at = NULL

只要没有退款或撤销,用户就可以一直访问对应商品。

定期订阅

订阅需要保存当前计费周期和订阅状态,例如:

CREATE TABLE subscriptions (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    product_id BIGINT UNSIGNED NOT NULL,
    provider VARCHAR(50) NOT NULL,
    provider_subscription_id VARCHAR(255) NOT NULL,
    status ENUM(
        'pending',
        'active',
        'past_due',
        'cancelled',
        'expired'
    ) NOT NULL,
    current_period_end DATETIME NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    UNIQUE KEY uq_provider_subscription (
        provider,
        provider_subscription_id
    ),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

判断订阅是否允许访问,不能只看用户是否点过订阅。通常至少要检查:

  • 付款是否已经确认;
  • 当前计费周期是否尚未结束;
  • 是否发生退款、拒付或管理员撤销;
  • 取消订阅后是立即失效,还是在当前周期结束后失效。

这些规则应统一写在授权服务或权限函数中,避免每个页面各写一套判断逻辑。

支付成功后的处理流程

常见的处理流程是:

  1. 用户在网站上选择商品。
  2. 服务器创建状态为 pending 的订单。
  3. 用户跳转到支付服务商完成支付。
  4. 支付服务商通过 Webhook 通知服务器。
  5. 服务器验证 Webhook 的签名和事件内容。
  6. 服务器在数据库事务中将订单状态改为 paid,同时创建或延长权益。
  7. 用户之后访问数据时,系统查询其有效权益。

不能因为浏览器跳转到了“支付成功”页面就发放权限。跳转地址可能被伪造,用户也可能在完成支付后直接关闭页面。权限发放应以通过签名验证的服务端支付通知为准。

Webhook 还可能重复发送通知。可以为支付事件 ID 或支付订单 ID 建立唯一索引,防止同一事件重复到达时反复创建权益。

商品粒度如何选择

是否要为每场比赛单独建立商品,取决于实际的销售方式:

  • 按单场销售:product_games 中的一个商品关联一场比赛。
  • 按赛季销售:一个赛季商品关联该赛季的全部比赛。
  • 按球队销售:商品关联某支球队的比赛。
  • 按数据字段销售:需要增加更细的数据权限,例如 basic_stats、player_stats、advanced_stats。
  • 全站订阅:权益关联一个全站套餐,查询时按照套餐规则授权。

如果比赛会不断增加,还需要明确“购买某赛季”是否自动包含之后录入的同赛季比赛。如果包含,可以根据赛季条件动态授权。如果购买内容必须保持不变,则应在购买时记录商品快照,或者固定商品与比赛之间的关联。

常见问题和安全注意事项

不要把支付记录直接当作唯一权限来源

订单记录已经发生的交易,权益记录用户当前可以访问的内容。将两者分开后,退款、赠送、试用、迁移和人工补偿都更容易处理。

不要相信客户端传入的用户身份

下面这种请求并不安全:

GET /games.php?user_id=123

用户可以把 123 改成其他编号。用户 ID 应从经过验证的服务端 Session 或访问令牌中取得。

所有出口都要执行授权

除了网页,JSON API、CSV 导出、图片、报表、下载链接和后台异步接口也必须执行相同的权限检查。否则,用户可能绕过页面,直接访问底层接口。

使用数据库事务

订单确认和权益创建应放在同一个事务中:

$pdo->beginTransaction();

try {
    // 更新订单状态
    // 创建或延长用户权益
    // 记录已处理的支付事件

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

这样可以避免订单已标记为支付成功,但权益没有创建成功的中间状态。

保留审计记录

支付、退款、权益发放和撤销最好保留不可随意覆盖的事件记录,其中包括:

  • 操作时间;
  • 订单及支付事件编号;
  • 权益变更前后的状态;
  • 操作由系统自动执行还是由管理员执行;
  • 退款、拒付及撤销原因。

正确处理密码和支付信息

PHP 密码应使用 password_hash() 保存,并通过 password_verify() 验证。不要自行保存银行卡号、CVV 等敏感支付信息,支付环节应交给合规的支付服务商处理。

做好索引和缓存隔离

数据规模增大后,应为 user_id、product_id、game_id、状态和有效期字段建立合适的索引。如果需要缓存查询结果,缓存键必须包含用户或权益范围,不能让不同用户共享未经隔离的私有数据缓存。

这类系统不该为每条数据添加布尔开关,而应建立清晰的用户、订单、权益、商品和数据关系。支付模块负责变更权益,授权模块负责验证权益,数据查询只返回通过验证的内容。这样的结构可以同时支持一次性购买、订阅、退款、套餐升级和后续扩展。

备注:内容仅供参考。