本文约 5,902 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:一个月跑掉 3TB 流量,账单比服务器还贵
有人把模板素材放在 WordPress 的 uploads 目录里,文章里直接写 https://站点/wp-content/uploads/xxx.zip。上线第一个月收到云厂商账单:CDN 流量 3TB,费用是服务器本身的四倍。查日志发现,这个 zip 被十几个采集站直接引用,还有人把它放进了自己的”资源合集”页面——所有下载请求都不经过他的站点,纯给别人贡献带宽。
资源下载站有三个绕不开的问题:文件放哪、谁能下、怎么防止被盗链。第一个决定成本结构,第二个决定商业模式,第三个决定站点能不能活下去。很多人的做法是”先放上去再说”,等账单来了才回头补救,此时外链已经扩散,改路径会导致大量失效引用。因此这三件事必须在建站初期就定好。
原理分析:三种下载方式的性能与安全性对比
第一种是公开直链,即文件放在 Web 可访问目录,直接给出 URL。优点是不消耗 PHP 进程,缺点是零权限控制、零防盗链。
第二种是 PHP 中间页校验,先验证登录态与 nonce,再用 readfile() 输出文件。优点是权限可控,缺点是大文件会长时间占用 PHP 进程和内存,几十个人同时下载就能把 PHP-FPM 打满。
第三种是 nginx 的 X-Accel-Redirect(Apache 对应 X-Sendfile):PHP 只负责校验,校验通过后返回一个特殊响应头,由 Web 服务器接管文件传输。这兼顾了权限控制与传输性能,是资源站的正确解法。
| 方式 | 权限控制 | 性能 | 防盗链 |
|---|---|---|---|
| 公开直链 | 无 | 高 | 无 |
| PHP readfile | 强 | 低 | 中 |
| X-Accel-Redirect | 强 | 高 | 强 |
防盗链则靠两个层面:一是 nginx 的 valid_referers 判断来源,二是签名链接(时间戳 + 密钥生成哈希,过期失效)。两者结合,既拦普通盗链,也防高级抓取。
保姆级实操
步骤 1:注册下载 CPT 与资源分类法。
add_action( 'init', 'mimangfei_register_download_cpt' );
function mimangfei_register_download_cpt() {
register_post_type( 'download_item', array(
'labels' => array(
'name' => '资源下载',
'singular_name' => '资源',
'add_new_item' => '添加资源',
),
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'downloads', 'with_front' => false ),
'supports' => array( 'title', 'editor', 'thumbnail' ),
'show_in_rest' => true,
'menu_icon' => 'dashicons-download',
) );
register_taxonomy( 'download_cat', 'download_item', array(
'label' => '资源分类',
'public' => true,
'hierarchical' => true,
'rewrite' => array( 'slug' => 'downloads/cat' ),
'show_in_rest' => true,
) );
}
步骤 2:用媒体库管理文件并记录元信息。 上传后记录文件 ID、大小、版本,便于前台展示。
add_action( 'save_post_download_item', function ( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
$att_id = isset( $_POST['download_file_id'] ) ? absint( $_POST['download_file_id'] ) : 0;
if ( ! $att_id ) {
return;
}
update_post_meta( $post_id, '_file_id', $att_id );
$path = get_attached_file( $att_id );
if ( $path && file_exists( $path ) ) {
update_post_meta( $post_id, '_file_size', filesize( $path ) );
update_post_meta( $post_id, '_file_hash', hash_file( 'sha256', $path ) );
}
} );
步骤 3:下载处理——校验后用 X-Accel-Redirect 输出。
add_action( 'template_redirect', function () {
if ( ! isset( $_GET['dl'] ) ) {
return;
}
$post_id = absint( $_GET['dl'] );
$nonce = isset( $_GET['_wpnonce'] ) ? $_GET['_wpnonce'] : '';
if ( ! wp_verify_nonce( $nonce, 'download_' . $post_id ) ) {
wp_die( '下载链接无效或已过期', '下载失败', array( 'response' => 403 ) );
}
if ( ! current_user_can( 'read' ) ) {
wp_die( '请先登录后再下载', '需要登录', array( 'response' => 401 ) );
}
$att_id = (int) get_post_meta( $post_id, '_file_id', true );
$path = $att_id ? get_attached_file( $att_id ) : '';
if ( ! $path || ! file_exists( $path ) ) {
wp_die( '文件不存在', '下载失败', array( 'response' => 404 ) );
}
$clicks = (int) get_post_meta( $post_id, '_dl_count', true );
update_post_meta( $post_id, '_dl_count', $clicks + 1 );
// 交给 nginx 传输文件,PHP 进程到此为止
nocache_headers();
header( 'Content-Type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . rawurlencode( basename( $path ) ) . '"' );
header( 'Content-Length: ' . filesize( $path ) );
header( 'X-Accel-Redirect: /protected-files/' . basename( $path ) );
exit;
} );
步骤 4:让文件目录不可直接访问。 文件放到 Web 根目录之外,只通过内部 location 暴露给 X-Accel-Redirect。
# 真实文件放在 /data/files,不对外暴露
location /protected-files/ {
internal;
alias /data/files/;
limit_rate 2m; # 单连接限速 2MB/s
limit_rate_after 20m; # 前 20MB 不限速
}
# 防盗链:仅允许本站与空 referer 访问静态资源
location ~* \.(zip|rar|7z|pdf|mp4)$ {
valid_referers none blocked server_names *.mimangfei.com;
if ($invalid_referer) {
return 403;
}
expires 7d;
add_header Cache-Control "public";
}
步骤 5:生成带有效期的签名下载链接。 用于临时分享,60 分钟后自动失效。
function mimangfei_signed_download_url( $post_id, $ttl = 3600 ) {
$secret = wp_salt( 'auth' );
$expire = time() + $ttl;
$sig = hash_hmac( 'sha256', $post_id . '|' . $expire, $secret );
return add_query_arg( array(
'dl' => $post_id,
'expires' => $expire,
'sig' => $sig,
), home_url( '/download/' ) );
}
步骤 6:前端输出下载按钮。 带上 nonce,防止链接被随意传播。
<?php
$post_id = get_the_ID();
$url = add_query_arg( array(
'dl' => $post_id,
'_wpnonce' => wp_create_nonce( 'download_' . $post_id ),
), home_url( '/download/' ) );
$size = size_format( (int) get_post_meta( $post_id, '_file_size', true ) );
?>
<a class="btn-download" href="<?php echo esc_url( $url ); ?>" rel="nofollow">
立即下载(<?php echo esc_html( $size ); ?>)
</a>
常见踩坑
- 用
readfile()输出 500MB 的大文件,并发一上来 PHP-FPM 进程全被占满,整个站点 502。 internallocation 的alias路径写错,下载时报 nginx 404 且错误日志提示 “open() … failed (2: No such file or directory)”。valid_referers把搜索引擎和微信内置浏览器的来源也拦了,正常分享打不开。- 文件仍在
wp-content/uploads/下,防盗链形同虚设,别人绕过中间页直接下载。 - nonce 默认 24 小时有效期,下载按钮被缓存后链接失效,页面显示 “The link you followed has expired.”
- 案例:某素材站用 PHP 直接输出文件,平时没问题。一次活动推流后 200 人同时下载一个 800MB 的压缩包,PHP 进程瞬间耗尽,不仅下载失败,连前台页面都打不开。改成 X-Accel-Redirect 后,同样的并发下 PHP 只承担校验,文件由 nginx 传输,CPU 占用下降九成。
- 下载计数写在页面渲染里,被缓存后计数不准(应放在跳转处理流程中)。
- 只做了 referer 防盗链,被人直接抓包拿 URL,完全没有时效性限制。
总结
资源站的验收标准是三个”能不能”:能不能精确控制”谁可以下载”、能不能防止文件被直接盗链、能不能在大并发下不影响正常访问。行动清单:① 文件放 Web 根目录之外;② 用 X-Accel-Redirect 而非 readfile();③ 下载链接带 nonce 或签名;④ valid_referers 放行空来源与搜索引擎;⑤ 限速与流量告警;⑥ 计数逻辑放在跳转流程中。存储与带宽成本如何压下来,见 资源站文件存储与流量成本、素材站会员下载权限设计 与 WordPress 防盗链配置。
