PHP 音乐资料 CRUD:数据库、媒体上传与安全删除
本文目录
原始发布日期保留为 2018-07-15。本文于 2026-10-19 重整为学习案例。旧版直接修改 JSON 文件、信任上传 MIME 和原始文件名、从 GET 删除记录并未转义列表内容;这些做法不应进入生产系统。下列代码是围绕安全边界的片段,不是可直接部署的完整后台。
把一次写操作拆成明确阶段
一个媒体资料创建流程可以按“鉴权与 CSRF、字段校验、上传验证、暂存媒体、事务写入、提交后响应”组织。每一步失败都要有可预测结果,并避免留下半条记录或孤立文件。
| 阶段 | 输入/动作 | 失败处理 |
|---|---|---|
| 权限与 CSRF | 检查当前用户角色和会话 token | 403 或表单错误,不改数据 |
| 字段校验 | 验证标题、歌手、字段类型和长度 | 返回字段级错误 |
| 上传验证 | 错误码、大小、真实 MIME、内容与配额 | 拒绝该文件,不信任浏览器声明 |
| 暂存与写入 | 随机文件名、受控目录、PDO 参数化 | 回滚数据库并清理临时文件 |
| 响应 | PRG 重定向或 JSON 状态 | 不输出异常、路径或内部配置 |
给后台表单加 required 或 accept 只改善界面;它不能替代服务端校验。每条媒体记录还要绑定创建者/租户等授权信息,避免只凭可猜测 ID 就能编辑、播放或删除他人的内容。
数据模型与 PDO
JSON 文件可用于单进程的一次性练习,但并发写入、查询、约束、审计和恢复都很弱。多用户管理场景应使用数据库,并由数据库约束兜底唯一性、非空字段和关联关系。
CREATE TABLE tracks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(160) NOT NULL,
artist VARCHAR(120) NOT NULL,
audio_key VARCHAR(255) NOT NULL,
cover_key VARCHAR(255) NULL,
owner_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
<?php
$statement = $pdo->prepare(
'INSERT INTO tracks (title, artist, audio_key, cover_key, owner_id)
VALUES (:title, :artist, :audio_key, :cover_key, :owner_id)'
);
$statement->execute([
'title' => $title,
'artist' => $artist,
'audio_key' => $audioKey,
'cover_key' => $coverKey,
'owner_id' => $currentUserId,
]);
?>
用户值必须通过 PDO 参数绑定;owner_id 从已验证的服务端会话获得,不能接受浏览器传来的所有者 ID。连接字符串和凭据从运行环境读取,并配置异常模式、超时、字符集与最小数据库权限。
媒体上传:检查内容,不信任名字
表单使用 POST、multipart/form-data 和 CSRF token。accept 只是文件选择提示;$_FILES['type']、name、扩展名和客户端提交的尺寸都不是可信证据。
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($_SESSION['csrf_token']) ?>">
<label for="title">标题</label>
<input id="title" name="title" maxlength="160" required>
<label for="artist">歌手</label>
<input id="artist" name="artist" maxlength="120" required>
<label for="audio">音频文件</label>
<input id="audio" name="audio" type="file" accept="audio/mpeg,audio/ogg,audio/wav" required>
<label for="cover">封面图片</label>
<input id="cover" name="cover" type="file" accept="image/jpeg,image/png,image/webp">
<button type="submit">保存</button>
</form>
服务端先检查 UPLOAD_ERR_OK、正整数大小上限和 is_uploaded_file(),再用 Fileinfo 检测实际内容类型。允许类型到扩展名使用固定映射;文件名用密码学随机值生成,上传目录从受控部署配置读取,并放在 Web 根目录之外。图片可以再解码、限制像素尺寸并重新编码;音频如需严格校验,应使用受限的媒体探测/转码流程,而不是只看 MIME。
<?php
$upload = $_FILES['audio'] ?? null;
$maxBytes = 20 * 1024 * 1024;
$allowedTypes = [
'audio/mpeg' => 'mp3',
'audio/ogg' => 'ogg',
'audio/wav' => 'wav',
];
if (!is_array($upload) || ($upload['error'] ?? null) !== UPLOAD_ERR_OK) {
throw new RuntimeException('音频上传失败。');
}
$size = $upload['size'] ?? null;
$tempPath = $upload['tmp_name'] ?? null;
if (!is_int($size) || $size < 1 || $size > $maxBytes) {
throw new RuntimeException('音频大小不符合限制。');
}
if (!is_string($tempPath) || !is_uploaded_file($tempPath)) {
throw new RuntimeException('上传来源无效。');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tempPath);
if (!is_string($mime) || !isset($allowedTypes[$mime])) {
throw new RuntimeException('不支持的音频类型。');
}
$storedName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mime];
$audioKey = $storedName;
$destination = $privateMediaDirectory . DIRECTORY_SEPARATOR . $storedName;
if (!move_uploaded_file($tempPath, $destination)) {
throw new RuntimeException('无法保存音频文件。');
}
?>
$privateMediaDirectory 必须由部署配置提供并在启动时校验权限;不要把它替换成用户可控路径。$audioKey 只是服务端生成的存储键,不是原始文件名,也不应直接当作公开 URL。
让文件系统与数据库失败时可恢复
数据库事务不能回滚磁盘移动。实用流程是先把验证后的媒体写入隔离暂存区,再开始数据库事务写入记录;提交失败时删除刚写入的文件,成功后由受控媒体路由按记录状态授权读取。还要有定期核对任务清理崩溃留下的孤儿暂存文件。
<?php
try {
$pdo->beginTransaction();
$statement->execute($trackParameters);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if (is_file($destination)) {
unlink($destination);
}
throw $error;
}
?>
示例展示失败补偿的核心思路,不包含异步队列、病毒扫描、对象存储、事务发件箱或可靠重试。更高可靠性需求应把媒体处理建模成待处理/可用/失败状态,并设计幂等任务和可观测的补偿流程。
列表输出与媒体访问
列表查询可以限制列、排序和分页;所有文本仍要在 HTML 输出点转义。音频/图片路径从数据库取出后也不能直接信任:把存储键映射到受控媒体 URL,或由检查记录权限的控制器返回文件。
<?php
$statement = $pdo->prepare(
'SELECT id, title, artist, audio_key, cover_key
FROM tracks
WHERE owner_id = :owner_id
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset'
);
$statement->bindValue('owner_id', $currentUserId, PDO::PARAM_INT);
$statement->bindValue('limit', 20, PDO::PARAM_INT);
$statement->bindValue('offset', $offset, PDO::PARAM_INT);
$statement->execute();
$tracks = $statement->fetchAll();
?>
<?php foreach ($tracks as $track): ?>
<article>
<h2><?= escapeHtml((string) $track['title']) ?></h2>
<p><?= escapeHtml((string) $track['artist']) ?></p>
<audio controls preload="none" src="<?= escapeHtml(mediaUrl((string) $track['audio_key'])) ?>"></audio>
</article>
<?php endforeach; ?>
LIMIT 和 OFFSET 的占位符行为会因数据库驱动而异;这里用 PDO 整数绑定。正式应用还应验证分页边界,并为大量数据改用基于游标的分页。媒体 API 必须检查登录态、记录状态和所有权;高流量文件可放到私有对象存储并用短时授权 URL。
删除:使用 CSRF 保护的 POST
删除是状态变更,不能通过可分享、可预取或可被爬虫访问的 GET 链接执行。后端先鉴权、校验 CSRF、验证 ID,并在查询中同时约束记录所有者。删除数据库行成功后,再按数据库保存的服务端存储键安排媒体删除;不要接受客户端提交的磁盘路径。
<form method="post" action="/admin/tracks/delete">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($_SESSION['csrf_token']) ?>">
<input type="hidden" name="track_id" value="<?= (int) $track['id'] ?>">
<button type="submit">删除</button>
</form>
删除后清理失败时,应记录一个可重试任务,不要让用户通过重放 GET 触发删除。重要数据应设置回收站/软删除与审计记录;物理删除需要清楚的保留策略和恢复方案。
上传与 CRUD 的回归清单
- 缺失字段、数组型输入、超长标题和空白名称都会得到明确校验结果。
- 文件上传覆盖超限、空文件、上传错误、伪造 MIME、双扩展名、路径穿越文件名和多图部分失败。
- 上传目录不可执行且不直接公开;下载路由校验认证、所有权和内容类型。
- 数据库失败会回滚并清理文件;重试请求不会重复创建记录。
- 删除只接受受鉴权和 CSRF 保护的 POST,并校验对象所有权。
- HTML 文本经上下文转义,SQL 使用参数绑定,日志不记录密码、Cookie 或原始文件内容。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。