如何根据 subscription 或 billing 显示 mySQL 数据
结论
不要在用户表中为每场比赛添加一个 True/False 字段。这种设计只适合数据量很小、内容也不会变化的演示系统。比赛或商品一多,就得频繁修改表结构,查询、退款和审计也会变得越来越麻烦。
更合适的设计是把系统分成三个部分:
- 数据:篮球比赛及其统计信息。
- 商品或套餐:用户实际购买的内容,例如某个赛季、某支球队、某类统计数据或完整数据包。
- 访问权益(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)
);
判断订阅是否允许访问,不能只看用户是否点过订阅。通常至少要检查:
- 付款是否已经确认;
- 当前计费周期是否尚未结束;
- 是否发生退款、拒付或管理员撤销;
- 取消订阅后是立即失效,还是在当前周期结束后失效。
这些规则应统一写在授权服务或权限函数中,避免每个页面各写一套判断逻辑。
支付成功后的处理流程
常见的处理流程是:
- 用户在网站上选择商品。
- 服务器创建状态为
pending的订单。 - 用户跳转到支付服务商完成支付。
- 支付服务商通过 Webhook 通知服务器。
- 服务器验证 Webhook 的签名和事件内容。
- 服务器在数据库事务中将订单状态改为
paid,同时创建或延长权益。 - 用户之后访问数据时,系统查询其有效权益。
不能因为浏览器跳转到了“支付成功”页面就发放权限。跳转地址可能被伪造,用户也可能在完成支付后直接关闭页面。权限发放应以通过签名验证的服务端支付通知为准。
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、状态和有效期字段建立合适的索引。如果需要缓存查询结果,缓存键必须包含用户或权益范围,不能让不同用户共享未经隔离的私有数据缓存。
这类系统不该为每条数据添加布尔开关,而应建立清晰的用户、订单、权益、商品和数据关系。支付模块负责变更权益,授权模块负责验证权益,数据查询只返回通过验证的内容。这样的结构可以同时支持一次性购买、订阅、退款、套餐升级和后续扩展。
备注:内容仅供参考。
