# CA381:PHP7.4 基于 SMB 的上传与 VIP 下载(完整示例)
> 说明:本示例以 **php-smbclient** 扩展 + **PDO(MySQL/MariaDB)** 为基础;目录规则按“按用户分目录 + 年/月分层”,文件名为 `{file_id}.{ext}`;下载端实现 **VIP/单品授权** 校验与 **Range** 断点(支持视频拖动)。
>
> 你可以把下面的文件逐个保存到你的 Web 根目录(如 `/home/ca381/www/culture/ca381_media/`)。
---
## 0) 数据表(DDL)——`create_tables.sql`
```sql
-- 会员信息(VIP)
CREATE TABLE IF NOT EXISTS user_memberships (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
vip_start_at DATETIME NULL,
vip_end_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_vip_period CHECK (vip_end_at IS NULL OR vip_end_at >= vip_start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 资源表
CREATE TABLE IF NOT EXISTS media_files (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
kind ENUM('doc','image','video','audio','other') NOT NULL,
mime_type VARCHAR(127) NOT NULL,
ext VARCHAR(16) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
duration_seconds INT UNSIGNED NULL,
width_px INT UNSIGNED NULL,
height_px INT UNSIGNED NULL,
sha256 CHAR(64) NOT NULL,
original_name VARCHAR(255) NOT NULL,
title VARCHAR(255) NULL,
description TEXT NULL,
storage_uri VARCHAR(512) NOT NULL,
storage_diskpath VARCHAR(512) NULL,
vip_required TINYINT(1) NOT NULL DEFAULT 1,
is_public TINYINT(1) NOT NULL DEFAULT 0,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_user (user_id),
INDEX idx_kind_time (kind, uploaded_at),
INDEX idx_uploaded_at (uploaded_at),
UNIQUE KEY uk_user_sha256 (user_id, sha256)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 资源访问授权(单品购买/赠送)
CREATE TABLE IF NOT EXISTS file_access_grants (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
file_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
grant_type ENUM('vip','purchase','admin') NOT NULL,
expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_file_user (file_id, user_id),
INDEX idx_user (user_id),
INDEX idx_file (file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 下载日志
CREATE TABLE IF NOT EXISTS download_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
file_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
ip VARCHAR(45) NOT NULL,
user_agent VARCHAR(255) NULL,
bytes_served BIGINT UNSIGNED NOT NULL,
downloaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_file_time (file_id, downloaded_at),
INDEX idx_user_time (user_id, downloaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
## 1) 配置文件——`config.php`
```php
<?php
// 数据库配置
return [
'db' => [
'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=ca381;charset=utf8mb4',
'user' => 'ca381',
'password' => 'your_db_password',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
],
// SMB 存储配置
'smb' => [
// 共享根:\tx01ca381uploads
// 使用 smb 流访问时拼为 smb://user:pass@tx01/ca381/uploads
'host' => 'tx01',
'share' => 'ca381',
'subdir' => 'uploads',
'user' => 'smb_user',
'password' => 'smb_pass',
// 若同时挂载到本地以便备份/运维,可填写本地挂载点,留空则不使用
'local_mount' => '/mnt/smb_ca381/uploads',
],
// 上传策略
'upload' => [
'max_bytes' => 1024 * 1024 * 1024, // 1GB 示例
// 扩展白名单(小写)
'allowed_exts' => [
'pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md',
'jpg','jpeg','png','gif','webp','bmp','svg',
'mp4','mov','m4v','avi','mkv','webm',
'mp3','aac','wav','flac','m4a'
],
// 可执行文件黑名单(强制拒绝)
'blocked_exts' => ['php','phtml','phar','cgi','exe','dll','sh','bat','cmd'],
],
];
```
---
## 2) PDO 连接——`db.php`
```php
<?php
$config = require __DIR__ . '/config.php';
$pdo = new PDO(
$config['db']['dsn'],
$config['db']['user'],
$config['db']['password'],
$config['db']['options']
);
```
---
## 3) 工具函数——`utils.php`
```php
<?php
function current_user_id(): int {
// 你已有登录系统可从 SESSION 里取;此处示例用会话
session_start();
if (!empty($_SESSION['user_id'])) return (int)$_SESSION['user_id'];
// 开发期可硬编码测试 UID=123
// $_SESSION['user_id'] = 123; return 123;
http_response_code(401);
exit('请先登录');
}
function cfg(): array { return require __DIR__ . '/config.php'; }
function build_smb_base(): string {
$c = cfg()['smb'];
$user = rawurlencode($c['user']);
$pass = rawurlencode($c['password']);
return sprintf('smb://%s:%s@%s/%s/%s', $user, $pass, $c['host'], $c['share'], trim($c['subdir'], '/'));
}
function norm_ext(string $filename): string {
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return preg_replace('/[^a-z0-9]+/','', $ext);
}
function guess_kind(string $mime, string $ext): string {
if (preg_match('#^image/#', $mime) || in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg'])) return 'image';
if (preg_match('#^video/#', $mime) || in_array($ext, ['mp4','mov','m4v','avi','mkv','webm'])) return 'video';
if (preg_match('#^audio/#', $mime) || in_array($ext, ['mp3','aac','wav','flac','m4a'])) return 'audio';
if (in_array($ext, ['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md'])) return 'doc';
return 'other';
}
function ensure_allowed_ext(string $ext): void {
$up = cfg()['upload'];
if (in_array($ext, $up['blocked_exts'], true)) {
http_response_code(400); exit('非法文件类型');
}
if (!in_array($ext, $up['allowed_exts'], true)) {
http_response_code(400); exit('不支持的扩展名');
}
}
function smb_mkdir_recursive(string $uri): bool {
// 逐层创建(smb:// 不总是支持 recursive=true 的 mkdir)
$parts = explode('/', trim($uri, '/'));
$path = '';
foreach ($parts as $i => $p) {
$path .= '/' . $p;
$full = 'smb://' . ltrim($path, '/');
if ($i < 3) continue; // 跳过 smb://host/share 根层
if (@is_dir($full)) continue;
if (!@mkdir($full)) return false;
}
return true;
}
function insert_media_placeholder(PDO $pdo, array $row): int {
$sql = "INSERT INTO media_files
(user_id, kind, mime_type, ext, size_bytes, sha256, original_name, title, description,
storage_uri, storage_diskpath, vip_required, is_public)
VALUES
(:user_id,:kind,:mime_type,:ext,:size_bytes,:sha256,
riginal_name,:title,:description,
:storage_uri,:storage_diskpath,:vip_required,:is_public)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':user_id' => $row['user_id'],
':kind' => $row['kind'],
':mime_type' => $row['mime_type'],
':ext' => $row['ext'],
':size_bytes' => $row['size_bytes'],
':sha256' => $row['sha256'],
'
riginal_name' => $row['original_name'],
':title' => $row['title'] ?? null,
':description' => $row['description'] ?? null,
':storage_uri' => $row['storage_uri'],
':storage_diskpath' => $row['storage_diskpath'] ?? null,
':vip_required' => $row['vip_required'] ?? 1,
':is_public' => $row['is_public'] ?? 0,
]);
return (int)$pdo->lastInsertId();
}
function update_media_storage(PDO $pdo, int $id, string $uri, ?string $diskpath): void {
$stmt = $pdo->prepare("UPDATE media_files SET storage_uri=:u, storage_diskpath=:d WHERE id=:id");
$stmt->execute([':u'=>$uri, ':d'=>$diskpath, ':id'=>$id]);
}
function is_vip(PDO $pdo, int $userId): bool {
$stmt = $pdo->prepare("SELECT vip_end_at FROM user_memberships WHERE user_id=?");
$stmt->execute([$userId]);
$r = $stmt->fetch();
return $r && (!empty($r['vip_end_at'])) && (strtotime($r['vip_end_at']) >= time());
}
function has_grant(PDO $pdo, int $userId, int $fileId): bool {
$stmt = $pdo->prepare("SELECT 1 FROM file_access_grants WHERE user_id=? AND file_id=? AND (expires_at IS NULL OR expires_at>=NOW()) LIMIT 1");
$stmt->execute([$userId, $fileId]);
return (bool)$stmt->fetchColumn();
}
function can_download(PDO $pdo, int $userId, array $file): bool {
if (!empty($file['deleted_at'])) return false;
if ((int)$file['vip_required'] === 0) return true;
if (is_vip($pdo, $userId)) return true;
if (has_grant($pdo, $userId, (int)$file['id'])) return true;
return false;
}
function log_download(PDO $pdo, int $fileId, int $userId, int $bytes): void {
$stmt = $pdo->prepare("INSERT INTO download_logs (file_id, user_id, ip, user_agent, bytes_served) VALUES (?,?,?,?,?)");
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
$stmt->execute([$fileId, $userId, $ip, $ua, $bytes]);
}
```
---
## 4) 上传表单(示例)——`upload_form.php`
```php
<?php require __DIR__ . '/utils.php'; current_user_id(); ?>
<!doctype html>
<html><head><meta charset="utf-8"><title>上传文件</title></head>
<body>
<h1>上传到 SMB(按用户/年月归档)</h1>
<form action="upload_handle.php" method="post" enctype="multipart/form-data">
<label>标题:<input type="text" name="title" maxlength="255"></label><br>
<label>描述:<textarea name="description" rows="3" cols="50"></textarea></label><br>
<label>是否需要VIP:
<select name="vip_required">
<option value="1" selected>是(默认)</option>
<option value="0">否(公开下载或仅预览)</option>
</select>
</label><br>
<label>文件:<input type="file" name="file" required></label><br>
<button type="submit">上传</button>
</form>
</body></html>
```
---
## 5) 上传处理(写 SMB + 入库)——`upload_handle.php`
```php
<?php
require __DIR__ . '/db.php';
require __DIR__ . '/utils.php';
$userId = current_user_id();
$cfg = cfg();
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); exit('上传失败');
}
$origName = $_FILES['file']['name'];
$tmp = $_FILES['file']['tmp_name'];
$size = (int)$_FILES['file']['size'];
if ($size <= 0 || $size > $cfg['upload']['max_bytes']) {
http_response_code(400); exit('文件大小不合法');
}
$ext = norm_ext($origName);
ensure_allowed_ext($ext);
$mime = mime_content_type($tmp) ?: 'application/octet-stream';
$kind = guess_kind($mime, $ext);
$sha256 = hash_file('sha256', $tmp);
// 去重:同一用户+内容唯一
$check = $pdo->prepare("SELECT id FROM media_files WHERE user_id=? AND sha256=? AND deleted_at IS NULL LIMIT 1");
$check->execute([$userId, $sha256]);
if ($exist = $check->fetchColumn()) {
exit('该文件已存在(同一内容已上传,ID='.$exist.')');
}
$title = trim($_POST['title'] ?? '');
$desc = trim($_POST['description'] ?? '');
$vipRequired = (int)($_POST['vip_required'] ?? 1);
// 先占位一条记录(临时使用占位路径,插入后拿到 file_id)
$placeholder_uri = 'smb://placeholder';
$diskpath = null;
$fileId = insert_media_placeholder($pdo, [
'user_id' => $userId,
'kind' => $kind,
'mime_type' => $mime,
'ext' => $ext,
'size_bytes' => $size,
'sha256' => $sha256,
'original_name' => $origName,
'title' => $title ?: null,
'description' => $desc ?: null,
'storage_uri' => $placeholder_uri,
'storage_diskpath' => $diskpath,
'vip_required' => $vipRequired,
'is_public' => 0,
]);
$base = build_smb_base(); // smb://user:pass@tx01/ca381/uploads
$year = date('Y'); $month = date('m');
$dir = sprintf('%s/user_%d/%s/%s', $base, $userId, $year, $month);
if (!smb_mkdir_recursive($dir)) { http_response_code(500); exit('创建 SMB 目录失败'); }
$target = $dir . '/' . $fileId . '.' . $ext;
$in = fopen($tmp, 'rb');
$out = fopen($target, 'wb');
if (!$in || !$out) { http_response_code(500); exit('打开文件句柄失败'); }
$bytes = stream_copy_to_stream($in, $out);
fclose($in); fclose($out);
if ($bytes !== $size) { http_response_code(500); exit('写入不完整'); }
// 更新存储路径
update_media_storage($pdo, $fileId, $target, null);
echo '上传成功,文件ID:' . $fileId;
```
---
## 6) 下载接口(VIP/授权校验 + Range 支持)——`download.php`
```php
<?php
require __DIR__ . '/db.php';
require __DIR__ . '/utils.php';
$userId = current_user_id();
$fileId = (int)($_GET['id'] ?? 0);
if ($fileId <= 0) { http_response_code(400); exit('参数错误'); }
$stmt = $pdo->prepare("SELECT * FROM media_files WHERE id=? LIMIT 1");
$stmt->execute([$fileId]);
$file = $stmt->fetch();
if (!$file) { http_response_code(404); exit('文件不存在'); }
if (!can_download($pdo, $userId, $file)) {
http_response_code(402); // Payment Required(语义合适)
exit('需要 VIP 或购买授权');
}
$uri = $file['storage_uri'];
$fp = @fopen($uri, 'rb');
if (!$fp) { http_response_code(500); exit('无法打开存储文件'); }
$size = (int)$file['size_bytes'];
$filename = $file['original_name'];
$mime = $file['mime_type'] ?: 'application/octet-stream';
// Range 处理(支持视频拖动)
$start = 0; $end = $size - 1; $httpStatus = 200;
if (isset($_SERVER['HTTP_RANGE'])) {
if (preg_match('/bytes=(d+)-(d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
if ($m[2] !== '') $end = (int)$m[2];
if ($end >= $size) $end = $size - 1;
if ($start > $end || $start >= $size) { http_response_code(416); exit; }
$httpStatus = 206;
}
}
$length = $end - $start + 1;
// 头部
header_remove('X-Powered-By');
http_response_code($httpStatus);
header("Content-Type: $mime");
header('Accept-Ranges: bytes');
header('Content-Disposition: attachment; filename="'. rawurlencode($filename) .'"');
if ($httpStatus === 206) {
header("Content-Range: bytes $start-$end/$size");
}
header("Content-Length: $length");
// 定位并输出
if ($start > 0) { fseek($fp, $start); }
$sent = 0; $buf = 8192;
while (!feof($fp) && $sent < $length) {
$need = min($buf, $length - $sent);
$chunk = fread($fp, $need);
if ($chunk === false) break;
echo $chunk;
$sent += strlen($chunk);
if (connection_aborted()) break;
}
fclose($fp);
log_download($pdo, $fileId, $userId, $sent);
```
---
## 7) 简单资源列表(分页)——`list.php`
```php
<?php
require __DIR__ . '/db.php';
require __DIR__ . '/utils.php';
$userId = current_user_id();
$page = max(1, (int)($_GET['page'] ?? 1));
$ps = 20; $off = ($page-1)*$ps;
$sql = "SELECT id, original_name, kind, mime_type, size_bytes, uploaded_at, vip_required FROM media_files
WHERE user_id=? AND deleted_at IS NULL ORDER BY id DESC LIMIT $ps OFFSET $off";
$rows = $pdo->prepare($sql); $rows->execute([$userId]); $rows = $rows->fetchAll();
?>
<!doctype html><html><head><meta charset="utf-8"><title>我的资源</title></head><body>
<h1>我的资源</h1>
<p><a href="upload_form.php">上传新文件</a></p>
<table border="1" cellpadding="6" cellspacing="0">
<tr><th>ID</th><th>文件名</th><th>类型</th><th>MIME</th><th>大小</th><th>时间</th><th>VIP</th><th>下载</th></tr>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= htmlspecialchars($r['id']) ?></td>
<td><?= htmlspecialchars($r['original_name']) ?></td>
<td><?= htmlspecialchars($r['kind']) ?></td>
<td><?= htmlspecialchars($r['mime_type']) ?></td>
<td><?= number_format($r['size_bytes']) ?></td>
<td><?= htmlspecialchars($r['uploaded_at']) ?></td>
<td><?= $r['vip_required'] ? '是' : '否' ?></td>
<td><a href="download.php?id=<?= (int)$r['id'] ?>">下载</a></td>
</tr>
<?php endforeach; ?>
</table>
</body></html>
```
---
## 8) Nginx/PHP 注意点
* **上传体积**:确保 `client_max_body_size`(Nginx)与 `post_max_size`、`upload_max_filesize`(PHP)足够大。
* **超时**:大文件上传适当放宽 `fastcgi_read_timeout`、`max_execution_time`。
* **Range**:`download.php` 已支持 `Range`;若前面还有反向代理,确保未去除 `Range` 头。
---
## 9) 可选增强
* 生成图片/视频 **低清预览**(`is_public=1`)与防直链签名 URL;
* 元数据抽取(FFprobe/ExifTool);
* 风控:同一 IP/用户下载频率限制;
* 全文检索(`FULLTEXT` + 中文分词)。
---
> 到这里就是“可直接跑”的最小闭环:上传 -> SMB 入库;下载 -> VIP/授权校验 + Range 断点。按你的环境把 `config.php` 中数据库与 SMB 账号改一下即可。