Files
YwxAppThink/ywxapp/library/StreamZipResponse.php
T

133 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// | 流式文件下载响应(避免 ThinkPHP download() 助手将整文件读入内存导致大文件/慢网络截断)
// +----------------------------------------------------------------------
namespace ywxapp\library;
use think\Response;
/**
* 流式输出二进制文件(zip 等)的下载响应。
*
* 相对 ThinkPHP 内置 download() 助手的优势:
* - 用 fread 分块输出(非 file_get_contents 整文件入内存),大文件不再吃爆 memory_limit
* - 构造函数内 set_time_limit(0),慢网络下不会被 Web 端默认执行时限截断;
* - 支持 HTTP RangeAccept-Ranges: bytes),客户端可断点续传,进一步抵御下载中断;
* - 正确回吐 Content-Length,便于客户端做字节数完整性校验。
*
* 用法:return new StreamZipResponse($absPath, $downloadName);
*/
class StreamZipResponse extends Response
{
protected $filePath;
protected $dlName;
protected $contentType = 'application/octet-stream';
public function __construct(string $filePath, string $dlName, int $code = 200)
{
$this->filePath = $filePath;
$this->dlName = $dlName;
$this->code = $code;
$this->header = [];
// 取消脚本执行时间限制:防止大文件 / 慢网络下被 PHP 默认 max_execution_time 截断
@set_time_limit(0);
@ignore_user_abort(false);
}
public function send(): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!is_file($this->filePath)) {
if (!headers_sent()) {
http_response_code(404);
}
return;
}
$size = filesize($this->filePath);
$name = rawurlencode($this->dlName);
// 解析 Range(断点续传)
$range = $this->parseRange($size);
$isRange = $range !== null;
$start = $range['start'] ?? 0;
$end = $range['end'] ?? ($size - 1);
if (!headers_sent()) {
http_response_code($isRange ? 206 : 200);
header('Pragma: public');
header('Accept-Ranges: bytes');
header('Content-Type: ' . $this->contentType);
header('Cache-control: max-age=360');
header('Content-Disposition: attachment; filename="' . $name . '"; filename* = UTF-8\'\'' . $name);
header('Content-Transfer-Encoding: binary');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 360) . ' GMT');
if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
} else {
header('Content-Length: ' . $size);
}
}
$this->outputRange($this->filePath, $start, $end);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
}
/**
* 解析 HTTP Range 头,返回 [start, end] 或 null(不支持/非法)
*/
protected function parseRange(int $size): ?array
{
$rangeHeader = $this->header['range'] ?? ($_SERVER['HTTP_RANGE'] ?? '');
if ($rangeHeader === '' || !preg_match('/bytes=(\d*)-(\d*)/i', (string) $rangeHeader, $m)) {
return null;
}
$start = $m[1] === '' ? 0 : (int) $m[1];
$end = $m[2] === '' ? ($size - 1) : (int) $m[2];
if ($start < 0 || $end >= $size || $start > $end) {
return null;
}
return ['start' => $start, 'end' => $end];
}
/**
* 分块输出指定区间内容
*/
protected function outputRange(string $path, int $start, int $end): void
{
$fp = fopen($path, 'rb');
if ($fp === false) {
return;
}
if ($start > 0) {
fseek($fp, $start);
}
$remaining = $end - $start + 1;
$chunk = 8192;
while ($remaining > 0 && !feof($fp)) {
$read = $remaining > $chunk ? $chunk : $remaining;
echo fread($fp, $read);
$remaining -= $read;
if (connection_status() !== 0) {
break;
}
}
fclose($fp);
}
}