mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-02 20:35:11 +00:00
feat(upload): 分片上传统一链路,5 场景突破单文件 1G 限制
- 新增 api/upload/{init,chunk,merge} 入口与 ChunkUpload 模块(5MB 分片、3 并发、Redis 状态机)
- 5 场景接入:文件柜 / 聊天 / 任务附件 / 头像&系统图片 / 编辑器粘贴
- 秒传:同用户同 hash 复用 FileContent 物理文件,零传输
- 续传:Redis + localStorage 双索引,24h TTL
- 与老接口对齐:pid 锁、≤300 上限、webkit_relative_path 目录递归、overwrite 替换语义
- init 阶段读 file_upload_limit 拦截超限,避免传完分片才报错
- DeleteTmpTask 加 tmp_chunks case 兜底清理 24h 未合并目录
- files 表新增 hash 列(migration)
- 前端 chunkedUpload wrapper:主线程 spark-md5 流式 + 指数退避重试
- ai-kb 同步:upload / file-upload-limit / upload-size-limit 三个 chunk
This commit is contained in:
parent
c04187fe47
commit
184fb27680
129
app/Http/Controllers/Api/UploadController.php
Normal file
129
app/Http/Controllers/Api/UploadController.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\ChunkUpload;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* 分片上传统一入口。
|
||||
*
|
||||
* 动态路由(routes/web.php):
|
||||
* api/upload/init -> init() 启动一个上传会话(含秒传 / 续传命中)
|
||||
* api/upload/chunk -> chunk() 接收一个分片
|
||||
* api/upload/merge -> merge() 合并分片并按 scene 入库
|
||||
*
|
||||
* 小文件(<10MB)不走此接口,前端直接调用各 scene 的老接口(透明降级)。
|
||||
*/
|
||||
class UploadController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/upload/init 启动上传会话
|
||||
*
|
||||
* @apiDescription 提交文件 hash/size/name/scene/scene_params,返回 upload_id 与已收分片列表;
|
||||
* 若同用户曾上传过同 hash 文件,直接返回 done=true(秒传)。
|
||||
* @apiGroup upload
|
||||
* @apiName init
|
||||
*
|
||||
* @apiParam {String} hash 文件 md5(小写 32 字符)
|
||||
* @apiParam {Number} size 文件大小(字节)
|
||||
* @apiParam {String} name 原始文件名(含扩展名)
|
||||
* @apiParam {String} scene 场景:file_cabinet | dialog_file | image | generic_file
|
||||
* @apiParam {Object} [scene_params] 场景参数(如 file_cabinet 需 pid/cover/webkit_relative_path)
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data 含 done / upload_id / chunk_size / chunk_count / received 或秒传 file
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
$user = User::auth();
|
||||
$result = ChunkUpload::start($user, [
|
||||
'hash' => Request::input('hash', ''),
|
||||
'size' => Request::input('size', 0),
|
||||
'name' => Request::input('name', ''),
|
||||
'scene' => Request::input('scene', ''),
|
||||
'scene_params' => Request::input('scene_params', []),
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/chunk 上传一个分片
|
||||
*
|
||||
* @apiDescription multipart 请求,blob 字段为分片数据。
|
||||
* @apiGroup upload
|
||||
* @apiName chunk
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
* @apiParam {Number} index 分片序号(0-based)
|
||||
* @apiParam {File} blob 分片数据
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data 含 upload_id 与最新 received[]
|
||||
*/
|
||||
public function chunk()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
$index = intval(Request::input('index', -1));
|
||||
$blob = Request::file('blob');
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
return ChunkUpload::receive($user, $uploadId, $index, $blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/merge 合并分片并入库
|
||||
*
|
||||
* @apiDescription 全部分片到齐后调用;后端按 scene 路由到对应入库逻辑,返回与该 scene 老接口对齐的数据。
|
||||
* @apiGroup upload
|
||||
* @apiName merge
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
* @apiSuccess {Object} data scene 入库返回数据
|
||||
*/
|
||||
public function merge()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
try {
|
||||
return ChunkUpload::merge($user, $uploadId);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
return Base::retError('合并繁忙,请稍后再试');
|
||||
}
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/upload/cancel 取消上传会话
|
||||
*
|
||||
* @apiDescription 调用方主动放弃一次分片上传时调用:删除 Redis meta/chunks/hash 索引并清掉分片目录。
|
||||
* 会话已过期或归属其他用户时静默成功,避免给前端取消按钮回写"取消失败"。
|
||||
* @apiGroup upload
|
||||
* @apiName cancel
|
||||
*
|
||||
* @apiParam {String} upload_id init 返回的 upload_id
|
||||
*
|
||||
* @apiSuccess {Number} ret
|
||||
*/
|
||||
public function cancel()
|
||||
{
|
||||
$user = User::auth();
|
||||
$uploadId = trim(Request::input('upload_id', ''));
|
||||
if ($uploadId === '') {
|
||||
return Base::retError('upload_id 不能为空');
|
||||
}
|
||||
ChunkUpload::cancelByUser($user, $uploadId);
|
||||
return Base::retSuccess('已取消');
|
||||
}
|
||||
}
|
||||
@ -267,20 +267,74 @@ class File extends AbstractModel
|
||||
* @return array
|
||||
*/
|
||||
public function contentUpload($user, int $pid, $webkitRelativePath, $overwrite = false)
|
||||
{
|
||||
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||
$data = Base::upload([
|
||||
"file" => Request::file('files'),
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, null, $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 contentUpload 同一入库链路,但接收已落盘的本地文件而非 Request 上传文件。
|
||||
* 供分片上传 merge 阶段调用。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $pid
|
||||
* @param string $localPath 合并后的完整文件绝对路径
|
||||
* @param string $originalName 原始文件名(含扩展名)
|
||||
* @param string $webkitRelativePath
|
||||
* @param string|null $hash 文件 md5,用于秒传索引
|
||||
* @param bool $overwrite
|
||||
* @return array
|
||||
*/
|
||||
public function contentUploadFromPath($user, int $pid, string $localPath, string $originalName, $webkitRelativePath, $hash = null, $overwrite = false)
|
||||
{
|
||||
[$pid, $userid, $addItem] = $this->contentUploadPrep($user, $pid, $webkitRelativePath);
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $localPath,
|
||||
"name" => $originalName,
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => 'uploads/tmp/file/' . date("Ym") . '/',
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
return $this->contentUploadCommit($user, $userid, $pid, $data['data'], $addItem, $webkitRelativePath, $hash, $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前置:权限/计数校验 + webkitRelativePath 拆出来的中间文件夹创建。
|
||||
* 失败抛 ApiException;成功返回 [最终 pid, 拥有者 userid, 已创建的中间文件夹列表]。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $pid
|
||||
* @param string $webkitRelativePath
|
||||
* @return array{0:int, 1:int, 2:array}
|
||||
*/
|
||||
public function contentUploadPrep($user, int $pid, $webkitRelativePath): array
|
||||
{
|
||||
$userid = $user->userid;
|
||||
if ($pid > 0) {
|
||||
if (File::wherePid($pid)->count() >= 300) {
|
||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
}
|
||||
$row = File::permissionFind($pid, $user, 1);
|
||||
$userid = $row->userid;
|
||||
} else {
|
||||
if (File::whereUserid($user->userid)->wherePid(0)->count() >= 300) {
|
||||
return Base::retError('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
}
|
||||
}
|
||||
//
|
||||
$dirs = explode("/", $webkitRelativePath);
|
||||
$addItem = [];
|
||||
while (count($dirs) > 1) {
|
||||
@ -297,12 +351,10 @@ class File extends AbstractModel
|
||||
'created_id' => $user->userid,
|
||||
]);
|
||||
$dirRow->handleDuplicateName();
|
||||
if ($dirRow->saveBeforePP()) {
|
||||
$addItem[] = File::find($dirRow->id);
|
||||
if (!$dirRow->saveBeforePP()) {
|
||||
throw new ApiException('创建文件夹失败');
|
||||
}
|
||||
}
|
||||
if (empty($dirRow)) {
|
||||
throw new ApiException('创建文件夹失败');
|
||||
$addItem[] = File::find($dirRow->id);
|
||||
}
|
||||
$pid = $dirRow->id;
|
||||
});
|
||||
@ -311,20 +363,24 @@ class File extends AbstractModel
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
$path = 'uploads/tmp/file/' . date("Ym") . '/';
|
||||
$data = Base::upload([
|
||||
"file" => Request::file('files'),
|
||||
"type" => 'more',
|
||||
"autoThumb" => false,
|
||||
"path" => $path,
|
||||
"quality" => true
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$data = $data['data'];
|
||||
//
|
||||
return [$pid, $userid, $addItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传后置:ext → type 映射 + File 记录创建 + uploadMove + FileContent 入库。
|
||||
*
|
||||
* @param user $user
|
||||
* @param int $userid 目标文件拥有者
|
||||
* @param int $pid
|
||||
* @param array $data Base::upload/uploadFromPath 返回的 data 部分
|
||||
* @param array $addItem prep 阶段累积的中间文件夹
|
||||
* @param string $webkitRelativePath
|
||||
* @param string|null $hash 可选,写入 files.hash(秒传索引)
|
||||
* @param bool $overwrite
|
||||
* @return array{data: array, addItem: array}
|
||||
*/
|
||||
private function contentUploadCommit($user, int $userid, int $pid, array $data, array $addItem, $webkitRelativePath, $hash, bool $overwrite): array
|
||||
{
|
||||
$type = match ($data['ext']) {
|
||||
'text', 'md', 'markdown' => 'document',
|
||||
'drawio' => 'drawio',
|
||||
@ -356,7 +412,6 @@ class File extends AbstractModel
|
||||
if ($data['ext'] == 'markdown') {
|
||||
$data['ext'] = 'md';
|
||||
}
|
||||
$file = null;
|
||||
$params = [
|
||||
'pid' => $pid,
|
||||
'name' => Base::rightDelete($data['name'], '.' . $data['ext']),
|
||||
@ -365,6 +420,7 @@ class File extends AbstractModel
|
||||
'userid' => $userid,
|
||||
'created_id' => $user->userid,
|
||||
];
|
||||
$file = null;
|
||||
if ($overwrite) {
|
||||
$file = self::wherePid($params['pid'])->whereExt($params['ext'])->whereName($params['name'])->first();
|
||||
}
|
||||
@ -373,11 +429,12 @@ class File extends AbstractModel
|
||||
$file = File::createInstance($params);
|
||||
$file->handleDuplicateName();
|
||||
}
|
||||
// 开始创建
|
||||
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file) {
|
||||
return AbstractModel::transaction(function () use ($overwrite, $addItem, $webkitRelativePath, $type, $user, $data, $file, $hash) {
|
||||
$file->size = $data['size'] * 1024;
|
||||
if ($hash) {
|
||||
$file->hash = $hash;
|
||||
}
|
||||
$file->saveBeforePP();
|
||||
//
|
||||
$data = Base::uploadMove($data, "uploads/file/" . $file->type . "/" . date("Ym") . "/" . $file->id . "/");
|
||||
$content = [
|
||||
'from' => '',
|
||||
@ -389,25 +446,20 @@ class File extends AbstractModel
|
||||
$content['width'] = $data['width'];
|
||||
$content['height'] = $data['height'];
|
||||
}
|
||||
$content = FileContent::createInstance([
|
||||
FileContent::createInstance([
|
||||
'fid' => $file->id,
|
||||
'content' => $content,
|
||||
'text' => '',
|
||||
'size' => $file->size,
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
$content->save();
|
||||
//
|
||||
])->save();
|
||||
$tmpRow = File::find($file->id);
|
||||
$tmpRow->pushMsg('add', $tmpRow);
|
||||
//
|
||||
$data = File::handleImageUrl($tmpRow->toArray());
|
||||
$data['full_name'] = $webkitRelativePath ?: ($data['name'] . '.' . $data['ext']);
|
||||
$data['overwrite'] = $overwrite ? 1 : 0;
|
||||
//
|
||||
$addItem[] = $data;
|
||||
|
||||
return ['data' => $data, 'addItem' => $addItem];
|
||||
$row = File::handleImageUrl($tmpRow->toArray());
|
||||
$row['full_name'] = $webkitRelativePath ?: ($row['name'] . '.' . $row['ext']);
|
||||
$row['overwrite'] = $overwrite ? 1 : 0;
|
||||
$addItem[] = $row;
|
||||
return ['data' => $row, 'addItem' => $addItem];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1100,6 +1100,46 @@ class WebSocketDialog extends AbstractModel
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 sendMsgFiles 同链路,但接收已落盘的本地文件(分片合并产物),跳过 Base::upload。
|
||||
*
|
||||
* @param User $user
|
||||
* @param int[] $dialogIds
|
||||
* @param string $localPath 已落盘绝对路径
|
||||
* @param string $originalName 原始文件名
|
||||
* @param int $replyId
|
||||
* @param bool $imageAttachment 任务群组中图片是否也作为附件保存
|
||||
* @return array
|
||||
*/
|
||||
public static function sendMsgFilesFromPath($user, $dialogIds, string $localPath, string $originalName, int $replyId = 0, bool $imageAttachment = false)
|
||||
{
|
||||
$first = null;
|
||||
$fileName = $originalName;
|
||||
$resolve = function ($path) use (&$first, &$fileName, $localPath, $originalName) {
|
||||
if ($first !== null) {
|
||||
return self::copyFileDataTo($first, $path);
|
||||
}
|
||||
$setting = Base::setting("system");
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $localPath,
|
||||
"name" => $originalName,
|
||||
"type" => 'more',
|
||||
"path" => $path,
|
||||
"fileName" => $fileName,
|
||||
"quality" => true,
|
||||
"convertVideo" => $setting['convert_video'] === 'open',
|
||||
"compressVideo" => $setting['compress_video'] === 'open',
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$first = $data['data'];
|
||||
$fileName = $first['name'];
|
||||
return $first;
|
||||
};
|
||||
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息文件
|
||||
*
|
||||
@ -1114,24 +1154,18 @@ class WebSocketDialog extends AbstractModel
|
||||
*/
|
||||
public static function sendMsgFiles($user, $dialogIds, $files, $image64, $fileName, $replyId, $imageAttachment)
|
||||
{
|
||||
$filePath = '';
|
||||
$result = [];
|
||||
$data = [];
|
||||
foreach ($dialogIds as $dialog_id) {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
|
||||
$action = $replyId > 0 ? "reply-$replyId" : "";
|
||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||
$first = null;
|
||||
$resolve = function ($path) use (&$first, &$fileName, $files, $image64) {
|
||||
if ($first !== null) {
|
||||
return self::copyFileDataTo($first, $path);
|
||||
}
|
||||
if ($image64) {
|
||||
$data = Base::image64save([
|
||||
"image64" => $image64,
|
||||
"path" => $path,
|
||||
"fileName" => $fileName,
|
||||
"quality" => true
|
||||
"quality" => true,
|
||||
]);
|
||||
} else if ($filePath) {
|
||||
Base::makeDir(public_path($path));
|
||||
copy($filePath, public_path($path) . basename($filePath));
|
||||
} else {
|
||||
$setting = Base::setting("system");
|
||||
$data = Base::upload([
|
||||
@ -1147,19 +1181,41 @@ class WebSocketDialog extends AbstractModel
|
||||
if (Base::isError($data)) {
|
||||
throw new ApiException($data['msg']);
|
||||
}
|
||||
$fileData = $data['data'];
|
||||
$filePath = $fileData['file'];
|
||||
$fileName = $fileData['name'];
|
||||
$fileData['thumb'] = Base::unFillUrl($fileData['thumb']);
|
||||
$fileData['size'] *= 1024;
|
||||
$first = $data['data'];
|
||||
$fileName = $first['name'];
|
||||
return $first;
|
||||
};
|
||||
return self::dispatchFileMessages($user, $dialogIds, $replyId, $imageAttachment, $resolve);
|
||||
}
|
||||
|
||||
// 任务群组保存文件
|
||||
/**
|
||||
* 遍历多个 dialog 发送文件消息:每个 dialog 取一份 fileData → 任务群组建附件 → sendMsg。
|
||||
* 取 fileData 的策略由 $resolve(path) 决定(首次 upload,后续 copy)。
|
||||
*
|
||||
* @param User $user
|
||||
* @param int[] $dialogIds
|
||||
* @param int $replyId
|
||||
* @param bool $imageAttachment
|
||||
* @param callable $resolve fn(string $path): array 返回该 dialog 的 fileData
|
||||
* @return array sendMsg 的最终返回(最后一个 dialog 的结果)
|
||||
*/
|
||||
private static function dispatchFileMessages($user, array $dialogIds, int $replyId, bool $imageAttachment, callable $resolve): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($dialogIds as $dialog_id) {
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
$action = $replyId > 0 ? "reply-$replyId" : "";
|
||||
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
|
||||
$fileData = $resolve($path);
|
||||
$fileData['thumb'] = Base::unFillUrl($fileData['thumb'] ?? '');
|
||||
$fileData['size'] *= 1024;
|
||||
$task = null;
|
||||
if ($dialog->group_type === 'task') {
|
||||
// 如果是图片不保存
|
||||
// 图片消息默认不作为任务附件存档,除非显式 $imageAttachment
|
||||
if ($imageAttachment || !in_array($fileData['ext'], File::imageExt)) {
|
||||
$task = ProjectTask::whereDialogId($dialog->id)->first();
|
||||
if ($task) {
|
||||
$file = ProjectTaskFile::createInstance([
|
||||
ProjectTaskFile::createInstance([
|
||||
'project_id' => $task->project_id,
|
||||
'task_id' => $task->id,
|
||||
'name' => $fileData['name'],
|
||||
@ -1168,20 +1224,30 @@ class WebSocketDialog extends AbstractModel
|
||||
'path' => $fileData['path'],
|
||||
'thumb' => $fileData['thumb'],
|
||||
'userid' => $user->userid,
|
||||
]);
|
||||
$file->save();
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'file', $fileData, $user->userid);
|
||||
if (Base::isSuccess($result)) {
|
||||
if (isset($task)) {
|
||||
$result['data']['task_id'] = $task->id;
|
||||
}
|
||||
if (Base::isSuccess($result) && $task) {
|
||||
$result['data']['task_id'] = $task->id;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把首个 dialog 上传得到的物理文件 copy 到后续 dialog 的目录,返回更新后的 fileData。
|
||||
*/
|
||||
private static function copyFileDataTo(array $first, string $path): array
|
||||
{
|
||||
Base::makeDir(public_path($path));
|
||||
$target = public_path($path) . basename($first['file']);
|
||||
copy($first['file'], $target);
|
||||
$copy = $first;
|
||||
$copy['file'] = $target;
|
||||
$copy['path'] = $path . basename($first['file']);
|
||||
$copy['url'] = Base::fillUrl($copy['path']);
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2074,8 +2074,20 @@ class Base
|
||||
*/
|
||||
public static function upload($param)
|
||||
{
|
||||
// 可选 key 默认值,下游直接访问不会 undefined index
|
||||
$param += [
|
||||
'chmod' => 0644,
|
||||
'saveName' => null,
|
||||
'scale' => null,
|
||||
'size' => 0,
|
||||
'fileName' => null,
|
||||
'quality' => null,
|
||||
'autoThumb' => null,
|
||||
'convertVideo' => null,
|
||||
'compressVideo' => null,
|
||||
];
|
||||
$file = $param['file'];
|
||||
$chmod = $param['chmod'] ?: 0644;
|
||||
$chmod = $param['chmod'];
|
||||
if (empty($file)) {
|
||||
return Base::retError("您没有选择要上传的文件");
|
||||
}
|
||||
@ -2130,6 +2142,9 @@ class Base
|
||||
$limitSize = intval($param['size']);
|
||||
if ($limitSize <= 0) {
|
||||
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||
if ($fileUploadLimit <= 0) {
|
||||
$fileUploadLimit = 1024;
|
||||
}
|
||||
$limitSize = $fileUploadLimit * 1024;
|
||||
}
|
||||
try {
|
||||
@ -2317,6 +2332,27 @@ class Base
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地文件包装成 UploadedFile(test=true) 转给 Base::upload,复用全套上传逻辑。
|
||||
* @param array $param path_local + name 为本方法特有,其余与 Base::upload 一致
|
||||
*/
|
||||
public static function uploadFromPath(array $param)
|
||||
{
|
||||
$localPath = $param['path_local'] ?? '';
|
||||
$name = $param['name'] ?? '';
|
||||
if (!$localPath || !is_file($localPath)) {
|
||||
return Base::retError('源文件不存在');
|
||||
}
|
||||
if (!$name) {
|
||||
$name = basename($localPath);
|
||||
}
|
||||
unset($param['path_local'], $param['name']);
|
||||
// test=true → UploadedFile::move 走 rename(),绕开 move_uploaded_file 的 is_uploaded_file 校验
|
||||
$param['file'] = new \Illuminate\Http\UploadedFile($localPath, $name, null, null, true);
|
||||
$param['fileName'] = $param['fileName'] ?? $name;
|
||||
return self::upload($param);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件移动
|
||||
* @param array $uploadResult
|
||||
|
||||
511
app/Module/ChunkUpload.php
Normal file
511
app/Module/ChunkUpload.php
Normal file
@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File as FileModel;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 分片上传核心:状态机与磁盘/Redis 调度。
|
||||
*
|
||||
* 流程:start → receive × N → merge → (scene dispatcher) → cleanup
|
||||
*
|
||||
* Redis key:
|
||||
* upload:{upload_id} → JSON 元数据 (TTL 24h)
|
||||
* upload:{upload_id}:chunks → SET 已收分片 index (TTL 24h)
|
||||
* upload:hash:{userid}:{hash} → upload_id 反查(续传 / 同 hash 复用) (TTL 24h)
|
||||
*
|
||||
* 磁盘:
|
||||
* uploads/tmp/chunks/{userid}/{upload_id}/{index}
|
||||
*/
|
||||
class ChunkUpload
|
||||
{
|
||||
/** 单个分片大小(5MB)。注意:要小于 Swoole package_max_length 1G */
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/** 状态/反查索引 TTL(秒):24h */
|
||||
const STATE_TTL = 86400;
|
||||
|
||||
/** 单文件硬上限(KB):系统设置之外的兜底保护,10G */
|
||||
const MAX_FILE_KB = 10 * 1024 * 1024;
|
||||
|
||||
/** 支持的 scene 枚举 */
|
||||
const SCENES = ['file_cabinet', 'dialog_file', 'image', 'generic_file'];
|
||||
|
||||
/**
|
||||
* 启动上传。
|
||||
* - 同用户同 hash 命中 files 表 → 秒传
|
||||
* - 同用户同 hash 命中 upload 反查 → 续传
|
||||
* - 否则新建 upload_id
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $param [hash, size(B), name, scene, scene_params(array)]
|
||||
* @return array
|
||||
*/
|
||||
public static function start(User $user, array $param): array
|
||||
{
|
||||
$hash = strtolower(trim($param['hash'] ?? ''));
|
||||
$size = intval($param['size'] ?? 0);
|
||||
$name = trim($param['name'] ?? '');
|
||||
$scene = trim($param['scene'] ?? '');
|
||||
$sceneParams = $param['scene_params'] ?? [];
|
||||
if (!is_array($sceneParams)) {
|
||||
$sceneParams = [];
|
||||
}
|
||||
|
||||
if (strlen($hash) !== 32) {
|
||||
return Base::retError('文件 hash 格式错误');
|
||||
}
|
||||
if ($size <= 0) {
|
||||
return Base::retError('文件大小无效');
|
||||
}
|
||||
if (intval(ceil($size / 1024)) > self::MAX_FILE_KB) {
|
||||
return Base::retError('文件超过系统支持的最大尺寸');
|
||||
}
|
||||
// init 时拦截系统配置上限,避免传完分片才在 merge 阶段被 Base::upload 拒绝
|
||||
$fileUploadLimit = intval(Base::settingFind('system', 'file_upload_limit', 0));
|
||||
if ($fileUploadLimit <= 0) {
|
||||
$fileUploadLimit = 1024;
|
||||
}
|
||||
if ($size > $fileUploadLimit * 1024 * 1024) {
|
||||
return Base::retError('文件大小超限,最大限制:' . $fileUploadLimit . 'MB');
|
||||
}
|
||||
if ($name === '') {
|
||||
return Base::retError('文件名不能为空');
|
||||
}
|
||||
if (!in_array($scene, self::SCENES, true)) {
|
||||
return Base::retError('不支持的上传场景');
|
||||
}
|
||||
|
||||
// 1) 秒传:同用户已上传过同 hash 文件 → 直接复用入库
|
||||
$hit = self::trySecondPass($user, $scene, $hash, $name, $sceneParams);
|
||||
if ($hit !== null) {
|
||||
return Base::retSuccess('success', $hit);
|
||||
}
|
||||
|
||||
// 2) 续传:同用户同 hash 有未完成上传
|
||||
$reuseKey = self::keyHashIndex($user->userid, $hash);
|
||||
$existingId = Redis::get($reuseKey);
|
||||
if ($existingId) {
|
||||
$meta = self::loadMeta($existingId);
|
||||
if ($meta && $meta['userid'] === $user->userid && $meta['hash'] === $hash) {
|
||||
return Base::retSuccess('success', self::sessionView($existingId, $meta));
|
||||
}
|
||||
// 反查指向了已失效的 upload_id,清掉
|
||||
Redis::del($reuseKey);
|
||||
}
|
||||
|
||||
// 3) 新建
|
||||
$uploadId = Base::generatePassword(32);
|
||||
$chunkCount = intval(ceil($size / self::CHUNK_SIZE));
|
||||
$meta = [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => $name,
|
||||
'scene' => $scene,
|
||||
'scene_params' => $sceneParams,
|
||||
'userid' => intval($user->userid),
|
||||
'chunk_size' => self::CHUNK_SIZE,
|
||||
'chunk_count' => $chunkCount,
|
||||
'created_at' => time(),
|
||||
];
|
||||
Redis::setex(self::keyMeta($uploadId), self::STATE_TTL, json_encode($meta, JSON_UNESCAPED_UNICODE));
|
||||
Redis::setex($reuseKey, self::STATE_TTL, $uploadId);
|
||||
Base::makeDir(self::chunkDir($user->userid, $uploadId));
|
||||
|
||||
return Base::retSuccess('success', self::sessionView($uploadId, $meta));
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收一个分片。
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $uploadId
|
||||
* @param int $index 分片序号(0-based)
|
||||
* @param UploadedFile|null $blob
|
||||
* @return array
|
||||
*/
|
||||
public static function receive(User $user, string $uploadId, int $index, $blob): array
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta) {
|
||||
return Base::retError('上传会话不存在或已过期');
|
||||
}
|
||||
if ($meta['userid'] !== intval($user->userid)) {
|
||||
return Base::retError('上传会话归属错误');
|
||||
}
|
||||
if ($index < 0 || $index >= $meta['chunk_count']) {
|
||||
return Base::retError('分片序号超出范围');
|
||||
}
|
||||
if (!$blob || !$blob->isValid()) {
|
||||
return Base::retError('分片数据无效');
|
||||
}
|
||||
// 最后一片可能小于 CHUNK_SIZE,其余必须等于
|
||||
$isLast = $index === $meta['chunk_count'] - 1;
|
||||
$chunkSize = $blob->getSize();
|
||||
if (!$isLast && $chunkSize !== self::CHUNK_SIZE) {
|
||||
return Base::retError('分片大小不符合预期');
|
||||
}
|
||||
if ($isLast) {
|
||||
$expectLast = $meta['size'] - self::CHUNK_SIZE * ($meta['chunk_count'] - 1);
|
||||
if ($chunkSize !== $expectLast) {
|
||||
return Base::retError('末尾分片大小不符合预期');
|
||||
}
|
||||
}
|
||||
$dir = self::chunkDir($user->userid, $uploadId);
|
||||
Base::makeDir($dir);
|
||||
$blob->move($dir, (string)$index);
|
||||
// 记录已收 + 续期三个相关 key
|
||||
Redis::sadd(self::keyChunks($uploadId), $index);
|
||||
Redis::expire(self::keyChunks($uploadId), self::STATE_TTL);
|
||||
Redis::expire(self::keyMeta($uploadId), self::STATE_TTL);
|
||||
Redis::expire(self::keyHashIndex($user->userid, $meta['hash']), self::STATE_TTL);
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'upload_id' => $uploadId,
|
||||
'received' => self::receivedList($uploadId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并分片并入库。需要在 Lock 内调用。
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $uploadId
|
||||
* @return array scene 入库返回结构(与 retSuccess/retError 对齐)
|
||||
*/
|
||||
public static function merge(User $user, string $uploadId): array
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta) {
|
||||
return Base::retError('上传会话不存在或已过期');
|
||||
}
|
||||
if ($meta['userid'] !== intval($user->userid)) {
|
||||
return Base::retError('上传会话归属错误');
|
||||
}
|
||||
$received = self::receivedList($uploadId);
|
||||
if (count($received) !== $meta['chunk_count']) {
|
||||
return Base::retError('分片不完整,无法合并');
|
||||
}
|
||||
|
||||
return Lock::withLock("upload:merge:{$uploadId}", function () use ($user, $uploadId, $meta) {
|
||||
$dir = self::chunkDir($user->userid, $uploadId);
|
||||
$mergedPath = $dir . '/merged.' . substr($meta['hash'], 0, 8);
|
||||
$writeFp = @fopen($mergedPath, 'wb');
|
||||
if (!$writeFp) {
|
||||
return Base::retError('无法创建合并文件');
|
||||
}
|
||||
// 拼接与 md5 同步进行:一遍磁盘读完成"写文件 + 算 hash"
|
||||
$hashCtx = hash_init('md5');
|
||||
try {
|
||||
for ($i = 0; $i < $meta['chunk_count']; $i++) {
|
||||
$partPath = $dir . '/' . $i;
|
||||
$readFp = @fopen($partPath, 'rb');
|
||||
if (!$readFp) {
|
||||
return Base::retError("分片读取失败:{$i}");
|
||||
}
|
||||
while (!feof($readFp)) {
|
||||
$buf = fread($readFp, 1024 * 1024);
|
||||
if ($buf === false) {
|
||||
fclose($readFp);
|
||||
return Base::retError("分片读取失败:{$i}");
|
||||
}
|
||||
fwrite($writeFp, $buf);
|
||||
hash_update($hashCtx, $buf);
|
||||
}
|
||||
fclose($readFp);
|
||||
}
|
||||
} finally {
|
||||
fclose($writeFp);
|
||||
}
|
||||
$actualHash = hash_final($hashCtx);
|
||||
if ($actualHash !== $meta['hash']) {
|
||||
@unlink($mergedPath);
|
||||
return Base::retError('文件校验失败,请重试');
|
||||
}
|
||||
|
||||
// 调用 scene 入库
|
||||
$result = self::dispatch($user, $meta, $mergedPath);
|
||||
|
||||
// 清理(无论成功失败都清,失败用户重新启 upload)
|
||||
self::cleanup($user->userid, $uploadId, $meta['hash']);
|
||||
|
||||
return $result;
|
||||
}, 60000, 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动取消:校验归属后清理。会话不存在或归属错误一律静默成功,前端取消按钮不需要分支处理。
|
||||
*/
|
||||
public static function cancelByUser(User $user, string $uploadId): void
|
||||
{
|
||||
$meta = self::loadMeta($uploadId);
|
||||
if (!$meta || intval($meta['userid'] ?? 0) !== $user->userid) {
|
||||
return;
|
||||
}
|
||||
self::cleanup($user->userid, $uploadId, $meta['hash'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理一个 upload_id 的所有状态。
|
||||
*/
|
||||
public static function cleanup(int $userid, string $uploadId, string $hash = ''): void
|
||||
{
|
||||
Redis::del(self::keyMeta($uploadId));
|
||||
Redis::del(self::keyChunks($uploadId));
|
||||
if ($hash) {
|
||||
Redis::del(self::keyHashIndex($userid, $hash));
|
||||
}
|
||||
$dir = self::chunkDir($userid, $uploadId);
|
||||
if (is_dir($dir)) {
|
||||
Base::deleteDirAndFile($dir);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== scene dispatcher =====
|
||||
|
||||
/**
|
||||
* 把合并后的本地文件交给对应 scene 入库。
|
||||
* 返回结构对齐各 scene 老接口的 retSuccess。
|
||||
*/
|
||||
protected static function dispatch(User $user, array $meta, string $mergedPath): array
|
||||
{
|
||||
$scene = $meta['scene'];
|
||||
$name = $meta['name'];
|
||||
$hash = $meta['hash'];
|
||||
$params = $meta['scene_params'] ?? [];
|
||||
|
||||
switch ($scene) {
|
||||
case 'file_cabinet':
|
||||
$pid = intval($params['pid'] ?? 0);
|
||||
$webkitRelativePath = strval($params['webkit_relative_path'] ?? $name);
|
||||
$overwrite = boolval($params['overwrite'] ?? false);
|
||||
// pid 锁避免与并发上传的 handleDuplicateName / 中间目录创建竞态
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$pid}", function () use ($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite) {
|
||||
$result = (new FileModel)->contentUploadFromPath($user, $pid, $mergedPath, $name, $webkitRelativePath, $hash, $overwrite);
|
||||
$outName = $result['data']['name'] ?? $name;
|
||||
return Base::retSuccess($outName . ' 上传成功', $result['addItem']);
|
||||
}, 120000, 120000);
|
||||
} catch (ApiException $e) {
|
||||
return Base::retError($e->getMessage());
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Failed to acquire lock')) {
|
||||
return Base::retError('上传繁忙,请稍后再试');
|
||||
}
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
|
||||
case 'image':
|
||||
// 头像 / 系统图片 / 编辑器粘贴图片,对齐 system/imgupload
|
||||
$width = intval($params['width'] ?? 0);
|
||||
$height = intval($params['height'] ?? 0);
|
||||
$whcut = strval($params['whcut'] ?? 'percentage');
|
||||
$whcut = match ($whcut) {
|
||||
'1' => 'cover',
|
||||
'0' => 'contain',
|
||||
'cover', 'contain' => $whcut,
|
||||
default => 'percentage',
|
||||
};
|
||||
$scale = [$width ?: 2160, $height ?: 4160, $whcut];
|
||||
$imagePath = "uploads/user/picture/" . $user->userid . "/" . date("Ym") . "/";
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $mergedPath,
|
||||
"name" => $name,
|
||||
"type" => 'image',
|
||||
"path" => $imagePath,
|
||||
"scale" => $scale,
|
||||
"quality" => true,
|
||||
]);
|
||||
if (Base::isError($data)) {
|
||||
return $data;
|
||||
}
|
||||
return Base::retSuccess('success', $data['data']);
|
||||
|
||||
case 'generic_file':
|
||||
// 编辑器粘贴文件 / 系统通用文件,对齐 system/fileupload
|
||||
$filePath = "uploads/user/file/" . $user->userid . "/" . date("Ym") . "/";
|
||||
$data = Base::uploadFromPath([
|
||||
"path_local" => $mergedPath,
|
||||
"name" => $name,
|
||||
"type" => 'file',
|
||||
"path" => $filePath,
|
||||
"quality" => true,
|
||||
]);
|
||||
return $data;
|
||||
|
||||
case 'dialog_file':
|
||||
// 聊天发文件 + 任务附件共用同一接入(任务附件本质是任务对话流的一条消息)
|
||||
$dialogIds = $params['dialog_ids'] ?? [];
|
||||
if (!is_array($dialogIds)) {
|
||||
$dialogIds = [$dialogIds];
|
||||
}
|
||||
$dialogIds = array_values(array_filter(array_map('intval', $dialogIds)));
|
||||
if (empty($dialogIds)) {
|
||||
return Base::retError('dialog_ids 不能为空');
|
||||
}
|
||||
$replyId = intval($params['reply_id'] ?? 0);
|
||||
$imageAttachment = boolval($params['image_attachment'] ?? false);
|
||||
try {
|
||||
return WebSocketDialog::sendMsgFilesFromPath($user, $dialogIds, $mergedPath, $name, $replyId, $imageAttachment);
|
||||
} catch (ApiException $e) {
|
||||
return Base::retError($e->getMessage());
|
||||
}
|
||||
|
||||
default:
|
||||
return Base::retError("scene 暂未实现:{$scene}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 hash 命中则在目标位置复用源 FileContent 指向的物理文件,零字节传输。
|
||||
* 未命中返回 null 让上层走真上传。
|
||||
*/
|
||||
protected static function trySecondPass(User $user, string $scene, string $hash, string $name, array $sceneParams): ?array
|
||||
{
|
||||
if ($scene !== 'file_cabinet') {
|
||||
return null;
|
||||
}
|
||||
$hit = FileModel::whereUserid($user->userid)->whereHash($hash)->whereNull('deleted_at')->first();
|
||||
if (!$hit) {
|
||||
return null;
|
||||
}
|
||||
$srcContent = FileContent::whereFid($hit->id)->orderByDesc('id')->first();
|
||||
if (!$srcContent) {
|
||||
return null;
|
||||
}
|
||||
$contentArr = is_array($srcContent->content)
|
||||
? $srcContent->content
|
||||
: json_decode($srcContent->content, true);
|
||||
if (empty($contentArr['url'])) {
|
||||
return null;
|
||||
}
|
||||
$rawPid = intval($sceneParams['pid'] ?? 0);
|
||||
$webkitRelativePath = strval($sceneParams['webkit_relative_path'] ?? $name);
|
||||
$overwrite = boolval($sceneParams['overwrite'] ?? false);
|
||||
|
||||
try {
|
||||
return Lock::withLock("file:upload:{$user->userid}:{$rawPid}", function () use ($user, $rawPid, $webkitRelativePath, $overwrite, $hit, $hash, $name, $contentArr) {
|
||||
[$pid, $userid, $addItem] = (new FileModel)->contentUploadPrep($user, $rawPid, $webkitRelativePath);
|
||||
|
||||
$ext = $hit->ext;
|
||||
$bareName = Base::rightDelete($name, '.' . $ext);
|
||||
$existing = null;
|
||||
if ($overwrite) {
|
||||
$existing = FileModel::wherePid($pid)->whereName($bareName)->whereExt($ext)->whereNull('deleted_at')->first();
|
||||
}
|
||||
if ($existing) {
|
||||
$existing->size = $hit->size;
|
||||
$existing->hash = $hash;
|
||||
$existing->type = $hit->type;
|
||||
if (!$existing->saveBeforePP()) {
|
||||
throw new ApiException('秒传保存失败');
|
||||
}
|
||||
FileContent::createInstance([
|
||||
'fid' => $existing->id,
|
||||
'content' => $contentArr,
|
||||
'text' => '',
|
||||
'size' => $existing->size,
|
||||
'userid' => $user->userid,
|
||||
])->save();
|
||||
$created = FileModel::find($existing->id);
|
||||
$overwriteFlag = 1;
|
||||
} else {
|
||||
$newFile = FileModel::createInstance([
|
||||
'pid' => $pid,
|
||||
'name' => $bareName,
|
||||
'type' => $hit->type,
|
||||
'ext' => $ext,
|
||||
'size' => $hit->size,
|
||||
'hash' => $hash,
|
||||
'userid' => $userid,
|
||||
'created_id' => $user->userid,
|
||||
]);
|
||||
$newFile->handleDuplicateName();
|
||||
if (!$newFile->saveBeforePP()) {
|
||||
throw new ApiException('秒传保存失败');
|
||||
}
|
||||
FileContent::createInstance([
|
||||
'fid' => $newFile->id,
|
||||
'content' => $contentArr,
|
||||
'text' => '',
|
||||
'size' => $newFile->size,
|
||||
'userid' => $user->userid,
|
||||
])->save();
|
||||
$created = FileModel::find($newFile->id);
|
||||
$overwriteFlag = 0;
|
||||
}
|
||||
$created->pushMsg($overwriteFlag ? 'update' : 'add', $created);
|
||||
$data = FileModel::handleImageUrl($created->toArray());
|
||||
$data['full_name'] = $name;
|
||||
$data['overwrite'] = $overwriteFlag;
|
||||
$addItem[] = $data;
|
||||
return [
|
||||
'done' => true,
|
||||
'instant' => true,
|
||||
'addItem' => $addItem,
|
||||
'msg' => $name . ' 秒传成功',
|
||||
];
|
||||
}, 120000, 120000);
|
||||
} catch (\Throwable $_e) {
|
||||
// 退化到真上传:错误由 dispatch 阶段权威报出,避免两条路径错误码不一致
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
protected static function keyMeta(string $uploadId): string
|
||||
{
|
||||
return "upload:{$uploadId}";
|
||||
}
|
||||
|
||||
protected static function keyChunks(string $uploadId): string
|
||||
{
|
||||
return "upload:{$uploadId}:chunks";
|
||||
}
|
||||
|
||||
protected static function keyHashIndex(int $userid, string $hash): string
|
||||
{
|
||||
return "upload:hash:{$userid}:{$hash}";
|
||||
}
|
||||
|
||||
protected static function chunkDir(int $userid, string $uploadId): string
|
||||
{
|
||||
return public_path("uploads/tmp/chunks/{$userid}/{$uploadId}");
|
||||
}
|
||||
|
||||
protected static function loadMeta(string $uploadId): ?array
|
||||
{
|
||||
$raw = Redis::get(self::keyMeta($uploadId));
|
||||
if (!$raw) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
protected static function receivedList(string $uploadId): array
|
||||
{
|
||||
$list = Redis::smembers(self::keyChunks($uploadId)) ?: [];
|
||||
$list = array_map('intval', $list);
|
||||
sort($list);
|
||||
return $list;
|
||||
}
|
||||
|
||||
protected static function sessionView(string $uploadId, array $meta): array
|
||||
{
|
||||
return [
|
||||
'done' => false,
|
||||
'upload_id' => $uploadId,
|
||||
'chunk_size' => $meta['chunk_size'],
|
||||
'chunk_count' => $meta['chunk_count'],
|
||||
'received' => self::receivedList($uploadId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -94,6 +94,27 @@ class DeleteTmpTask extends AbstractTask
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tmp_chunks':
|
||||
// 分片上传残留:upload_id 目录超过 hours 小时未合并则整目录清掉
|
||||
$chunksRoot = public_path('uploads/tmp/chunks');
|
||||
if (!is_dir($chunksRoot)) {
|
||||
break;
|
||||
}
|
||||
$cutoff = time() - 3600 * $this->hours;
|
||||
foreach (glob($chunksRoot . '/*', GLOB_ONLYDIR) ?: [] as $userDir) {
|
||||
foreach (glob($userDir . '/*', GLOB_ONLYDIR) ?: [] as $uploadDir) {
|
||||
$mtime = @filemtime($uploadDir);
|
||||
if ($mtime && $mtime < $cutoff) {
|
||||
Base::deleteDirAndFile($uploadDir);
|
||||
}
|
||||
}
|
||||
// 顺手清理空 user 目录
|
||||
if (count(scandir($userDir) ?: []) <= 2) {
|
||||
@rmdir($userDir);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_device':
|
||||
UserDevice::where('expired_at', '<', Carbon::now()->subHours($this->hours))
|
||||
->orderBy('id')
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->char('hash', 32)->nullable()->after('size')->comment('文件内容 md5(分片上传秒传用)');
|
||||
$table->index('hash', 'files_hash_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->dropIndex('files_hash_index');
|
||||
$table->dropColumn('hash');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1021,3 +1021,22 @@ AI 助手
|
||||
密钥无效
|
||||
应用未安装
|
||||
菜单不存在
|
||||
源文件不存在
|
||||
文件 hash 格式错误
|
||||
文件大小无效
|
||||
文件超过系统支持的最大尺寸
|
||||
文件名不能为空
|
||||
不支持的上传场景
|
||||
上传会话不存在或已过期
|
||||
上传会话归属错误
|
||||
分片序号超出范围
|
||||
分片数据无效
|
||||
分片大小不符合预期
|
||||
末尾分片大小不符合预期
|
||||
分片读取失败:(*)
|
||||
分片不完整,无法合并
|
||||
无法创建合并文件
|
||||
文件校验失败,请重试
|
||||
scene 暂未实现:(*)
|
||||
upload_id 不能为空
|
||||
合并繁忙,请稍后再试
|
||||
|
||||
@ -1434,6 +1434,16 @@ License Key
|
||||
私聊禁言
|
||||
群聊禁言
|
||||
默认不限制
|
||||
默认 1G
|
||||
准备中...
|
||||
上传中...
|
||||
合并中...
|
||||
上传已暂停
|
||||
网络异常,重试中...
|
||||
上传初始化失败
|
||||
分片上传失败
|
||||
合并失败
|
||||
分片重试耗尽
|
||||
开放:所有人都可以在全员群组发言。
|
||||
开放:所有人都可以相互发起个人聊天。
|
||||
开放:允许个人群组聊天发言。
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
"resolve-url-loader": "^4.0.0",
|
||||
"sass": "1.77.4",
|
||||
"sass-loader": "14.2.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"stylus": "^0.59.0",
|
||||
"stylus-loader": "^7.1.0",
|
||||
"tinymce": "^5.10.3",
|
||||
|
||||
@ -17,9 +17,9 @@ related_pages: [file_upload, dialog_send_file]
|
||||
prerequisites: []
|
||||
negative:
|
||||
- 单个上传请求受 PHP / Nginx 双层限制,前端的「最大尺寸」提示只是友好封装
|
||||
- 没有「拆包重传」按钮,超大文件需要事先分块或直接上传到外部存储
|
||||
- 同一文件多次重传不会绕过大小限制
|
||||
last_verified: v1.7.90
|
||||
- ≥10MB 文件已自动走分片上传,不需要手动拆包;超过系统设置上限时由应用层拒绝
|
||||
- 同一 hash 文件可秒传命中,但仍受系统设置的"单文件上限"约束
|
||||
last_verified: v1.8.45
|
||||
---
|
||||
|
||||
# 文件超大上传失败
|
||||
@ -37,14 +37,15 @@ DooTask 文件上传受三层限制:
|
||||
任何一层超出都会拒收。出错信息以最先拒绝的那层为准——浏览器层先校验则提示「超出文件大小限制」;后端先校验则提示 413。
|
||||
|
||||
## 解决
|
||||
1. 直接拖文件到「文件中心」走文件上传流程,限制最大(默认 1024M)
|
||||
2. 大于上限时拆分:用 zip 分卷、或上传到云盘后分享外链
|
||||
3. 聊天里发大文件不要走「图片」入口(小限),用「发送文件」按钮(大限)
|
||||
4. 管理员可调高限制:改 `docker/php/php.ini` 的 `upload_max_filesize` 和 `post_max_size`,同时改 nginx 的 `client_max_body_size`,重启容器生效
|
||||
1. 默认 1G 内的文件应直接成功;≥10MB 自动走分片上传(聊天/文件柜/任务附件/编辑器/头像统一)
|
||||
2. 同一 hash 文件秒传命中:上传过的视频再次发,瞬间出现在目标位置,零等待
|
||||
3. 中途断网或刷新页面,重新选同一文件会跳过已上传分片继续传
|
||||
4. 管理员需要更大上限:管理后台「系统设置」→「文件上传限制」填具体 MB 数(如 `5120`=5G),即可突破默认 1G
|
||||
5. 仅当填的值超过 PHP/Nginx/Swoole 底层上限时才需要改 `docker/php/php.ini` 等部署配置(默认底层均为 1G,因为单分片只有 5MB 所以分片路径不受底层约束)
|
||||
|
||||
## 不支持
|
||||
- 客户端不会自动分片重传超过 `post_max_size` 的文件
|
||||
- 主程序未内置 chunked upload / 断点续传 API(除部分插件场景)
|
||||
- 跨设备 / 跨浏览器续传(续传索引保存在 localStorage,仅本机本浏览器内有效)
|
||||
- 完全无认证场景下分片(必须登录)
|
||||
- 修改 php.ini 后必须容器重启,热加载不生效
|
||||
|
||||
[[file.upload.howto]] / [[system-setting.file.howto]] 给管理员看
|
||||
|
||||
@ -19,8 +19,8 @@ negative:
|
||||
- 同一用户在同一目录下并发上传会自动排队(避免数据库死锁),慢但不会丢
|
||||
- 单个文件夹直接子项上限 300,超出会报错
|
||||
- 不支持上传文件夹后保留文件夹下原有空目录(空目录会被忽略)
|
||||
- 不支持断点续传,超大文件请先压缩
|
||||
last_verified: v1.7.90
|
||||
- 跨设备续传不可用:续传索引存浏览器 localStorage,仅本机本浏览器内有效
|
||||
last_verified: v1.8.45
|
||||
---
|
||||
|
||||
# 上传文件
|
||||
@ -47,7 +47,14 @@ last_verified: v1.7.90
|
||||
- 父目录:当前所在文件夹
|
||||
- 版本:上传产生一条新的 FileContent 记录(详见 [[file.version.concept]])
|
||||
|
||||
## 大文件 / 断点续传 / 秒传
|
||||
- **≥10MB 自动分片**:文件切成 5MB 分片,3 路并发上传;前端先在 Web Worker 算 md5(不卡 UI)
|
||||
- **断点续传**:上传中刷新页面或断网后,重新选同一文件会跳过已上传分片,从中断处继续
|
||||
- **秒传**:同用户已上传过同 hash 文件(命中 `files.hash`),目标文件夹立刻出现新记录,零传输
|
||||
- 状态保存:服务端 Redis `upload:*` key TTL 24h;浏览器 localStorage `chunked_upload:<hash>` 索引同样 24h
|
||||
- 残留磁盘自动清理:`uploads/tmp/chunks/{userid}/{upload_id}/` 超过 24h 由 `DeleteTmpTask` 兜底清除
|
||||
|
||||
## 不支持
|
||||
- 无法向直接子项 ≥ 300 的文件夹继续上传(会被拒绝)
|
||||
- 不支持断点续传 / 分片上传(超大文件建议先压缩)
|
||||
- 跨设备 / 跨浏览器续传(localStorage 索引仅本机有效;服务端按 hash+userid 反查可兜底)
|
||||
- 不支持移动端后台续传(应用切到后台可能中断)
|
||||
|
||||
@ -19,9 +19,9 @@ related_tools: []
|
||||
related_pages: []
|
||||
negative:
|
||||
- 该限制只控制单个文件大小,不是磁盘配额或总量上限
|
||||
- 留空 = 不限制;后端仍受 PHP / Nginx 层 client_max_body_size 等服务级限制
|
||||
- 留空 = 默认 1G(前后端兜底一致);填具体值后取该值;仍受 PHP / Nginx 层服务级限制
|
||||
- 不能按用户 / 部门设差异化阈值,全局生效
|
||||
last_verified: v1.7.90
|
||||
last_verified: v1.8.45
|
||||
---
|
||||
|
||||
# 单文件上传大小限制
|
||||
@ -29,23 +29,28 @@ last_verified: v1.7.90
|
||||
## 入口
|
||||
桌面端:左上角头像 →「系统设置」→「系统设置」标签 →「其他设置」→「文件上传限制」。
|
||||
|
||||
字段名:`file_upload_limit`,整数,单位 **MB**,默认留空(= 不限制)。
|
||||
字段名:`file_upload_limit`,整数,单位 **MB**,默认留空(前端 placeholder 显示「默认 1G」)。
|
||||
|
||||
## 生效范围
|
||||
后端 `Base::uploadFile` 在每次接收上传时都会读取该值,作用于:
|
||||
后端 `Base::upload` / `Base::uploadFromPath` 在每次接收上传时都会读取该值,作用于:
|
||||
|
||||
- **聊天消息中发送的文件 / 图片附件**
|
||||
- **任务详情里的附件上传**
|
||||
- **「文件」应用中的文档上传**
|
||||
- 各种自定义上传入口(凡是走 `Base::uploadFile` 的接口)
|
||||
- 各种自定义上传入口(凡是走 `Base::upload` 的接口)
|
||||
|
||||
逻辑:调用方未显式传 `size` 参数时,取 `file_upload_limit * 1024 KB` 作为单文件上限。超过则报错 `文件大小超限,最大限制:N KB`。
|
||||
逻辑:调用方未显式传 `size` 参数时,先取 `file_upload_limit`;为空则按 **1024 MB(1G)兜底**;超过则报错 `文件大小超限,最大限制:N KB`。
|
||||
|
||||
## 与分片上传的关系
|
||||
- 前端 ≥ 10MB 自动走分片上传(单分片 5MB),跟系统设置的"单文件上限"是两件事
|
||||
- 提高 `file_upload_limit` 后,分片上传可以突破老的 1G 限制(每个分片远小于底层 PHP/Nginx/Swoole 限制)
|
||||
- 例如填 `5120`(5G)后,5G 视频可通过分片上传完成(受磁盘 / 内存等部署能力约束)
|
||||
|
||||
## 字段默认值
|
||||
|
||||
| 字段 | 默认 | 单位 |
|
||||
|---|---|---|
|
||||
| `file_upload_limit` | 空(不限制) | MB |
|
||||
| `file_upload_limit` | 空(按 1G 兜底) | MB |
|
||||
|
||||
## 操作步骤
|
||||
1. 进入「系统设置」→「系统设置」→「其他设置」
|
||||
|
||||
@ -77,6 +77,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../store/chunkedUpload";
|
||||
|
||||
export default {
|
||||
name: 'ImgUpload',
|
||||
props: {
|
||||
@ -315,7 +317,7 @@ export default {
|
||||
desc: this.$L('文件 ' + file.name + ' 太大,不能超过:' + $A.bytesToSize(this.maxImageSize * 1024))
|
||||
});
|
||||
},
|
||||
handleBeforeUpload() {
|
||||
handleBeforeUpload(file) {
|
||||
//上传前判断
|
||||
let check = this.uploadList.length < this.maxNum;
|
||||
if (!check && this.uploadList.length == 1) {
|
||||
@ -324,9 +326,59 @@ export default {
|
||||
}
|
||||
if (!check) {
|
||||
$A.noticeWarning(this.$L('最多只能上传 ' + this.maxNum + ' 张图片。'));
|
||||
return false;
|
||||
}
|
||||
// ≥ 10MB 走分片(iview max-size 拦在前,需调用方放大 maxSize 才能进到这里)
|
||||
if (file && file.size >= CHUNK_THRESHOLD) {
|
||||
this.handleChunkedUpload(file);
|
||||
return false;
|
||||
}
|
||||
return check;
|
||||
},
|
||||
|
||||
async handleChunkedUpload(rawFile) {
|
||||
// 与原 iview 路径同效果:成功后构造 fileList item 触发 handleCallback
|
||||
this.$emit('update:uploadIng', this.uploadIng + 1);
|
||||
const item = {
|
||||
uid: 'chunked-' + Date.now() + '-' + Math.random().toString(36).slice(2),
|
||||
name: rawFile.name,
|
||||
size: rawFile.size,
|
||||
status: 'uploading',
|
||||
showProgress: true,
|
||||
percentage: 0,
|
||||
};
|
||||
this.$refs.upload.fileList.push(item);
|
||||
this.uploadList = this.$refs.upload.fileList;
|
||||
try {
|
||||
const data = await chunkedUpload({
|
||||
file: rawFile,
|
||||
scene: 'image',
|
||||
sceneParams: {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
whcut: this.whcut,
|
||||
},
|
||||
onProgress: percent => { item.percentage = percent; },
|
||||
});
|
||||
item.status = 'finished';
|
||||
item.percentage = 100;
|
||||
item.url = data.url;
|
||||
item.path = data.path;
|
||||
item.thumb = data.thumb;
|
||||
this.handleCallback(item);
|
||||
this.$emit('input', this.$refs.upload.fileList);
|
||||
} catch (err) {
|
||||
$A.noticeWarning({
|
||||
title: this.$L('上传失败'),
|
||||
desc: this.$L('文件 ' + rawFile.name + ' 上传失败 ' + ((err && err.message) || '')),
|
||||
});
|
||||
const idx = this.$refs.upload.fileList.indexOf(item);
|
||||
if (idx > -1) this.$refs.upload.fileList.splice(idx, 1);
|
||||
this.$emit('input', this.$refs.upload.fileList);
|
||||
} finally {
|
||||
this.$emit('update:uploadIng', this.uploadIng - 1);
|
||||
}
|
||||
},
|
||||
handleClick() {
|
||||
//手动上传
|
||||
if (this.handleBeforeUpload()) {
|
||||
|
||||
@ -69,6 +69,7 @@ import tinymce from 'tinymce/tinymce';
|
||||
import ImgUpload from "./ImgUpload";
|
||||
import {mapState} from "vuex";
|
||||
import {languageName} from "../language";
|
||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../store/chunkedUpload";
|
||||
|
||||
const windowTouch = "ontouchend" in document
|
||||
|
||||
@ -686,10 +687,33 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
handleBeforeUpload() {
|
||||
handleBeforeUpload(file) {
|
||||
//上传前判断
|
||||
if (file && file.size >= CHUNK_THRESHOLD) {
|
||||
this.handleChunkedUpload(file);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async handleChunkedUpload(rawFile) {
|
||||
this.uploadIng++;
|
||||
try {
|
||||
const data = await chunkedUpload({
|
||||
file: rawFile,
|
||||
scene: 'generic_file',
|
||||
sceneParams: {},
|
||||
});
|
||||
this.insertContent(`<a href="${data.url}" target="_blank">${data.name} (${$A.bytesToSize(data.size * 1024)})</a>`);
|
||||
} catch (err) {
|
||||
$A.noticeWarning({
|
||||
title: this.$L('上传失败'),
|
||||
desc: this.$L('文件 ' + rawFile.name + ' 上传失败,' + ((err && err.message) || '')),
|
||||
});
|
||||
} finally {
|
||||
this.uploadIng--;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
<script>
|
||||
import {mapGetters} from "vuex";
|
||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../../../store/chunkedUpload";
|
||||
|
||||
export default {
|
||||
name: 'DialogUpload',
|
||||
@ -38,6 +39,7 @@ export default {
|
||||
fileMsgCaches: {}, // 文件信息缓存
|
||||
uploadFormat: [], // 不限制上传文件类型
|
||||
actionUrl: $A.apiUrl('dialog/msg/sendfile'),
|
||||
chunkedTasks: {}, // uid -> {controller, uploadId} 仅分片路径在跑的任务,供 cancel() 命中
|
||||
}
|
||||
},
|
||||
|
||||
@ -101,6 +103,11 @@ export default {
|
||||
|
||||
handleBeforeUpload(file) {
|
||||
//上传前
|
||||
// ≥ 10MB 走分片上传(拦截 iview 原生 action POST)
|
||||
if (file.size >= CHUNK_THRESHOLD) {
|
||||
this.handleChunkedUpload(file);
|
||||
return false;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.fileMsgData(file)
|
||||
if (/\.(jpe?g|webp|png|gif)$/i.test(file.name)) {
|
||||
@ -117,6 +124,78 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
async handleChunkedUpload(rawFile) {
|
||||
// 大文件分片上传:构造与 iview file 同 shape 的伪 file 对象,
|
||||
// 沿用原 on-progress/on-success/on-error 协议给父级 DialogWrapper
|
||||
this.fileMsgData(rawFile);
|
||||
if (/\.(jpe?g|webp|png|gif)$/i.test(rawFile.name)) {
|
||||
try {
|
||||
const imgData = await this.imageFileToObject(rawFile);
|
||||
this.fileMsgData(rawFile, imgData);
|
||||
} catch (_e) { /* 图片预处理失败不阻断上传 */ }
|
||||
}
|
||||
const pseudo = {
|
||||
uid: 'chunked-' + Date.now() + '-' + Math.random().toString(36).slice(2),
|
||||
name: rawFile.name,
|
||||
size: rawFile.size,
|
||||
status: 'uploading',
|
||||
percentage: 0,
|
||||
showProgress: true, // DialogWrapper.chatFile 根据此字段决定是否渲染进度
|
||||
};
|
||||
if (this.$parent.$options.name === 'DialogWrapper') {
|
||||
pseudo.tempId = this.$parent.getTempId();
|
||||
} else {
|
||||
pseudo.tempId = $A.randNum(1000000000, 9999999999);
|
||||
}
|
||||
pseudo.msg = {};
|
||||
const msgName = this.fileMsgName(rawFile);
|
||||
if (this.fileMsgCaches[msgName]) {
|
||||
pseudo.msg = this.fileMsgCaches[msgName];
|
||||
delete this.fileMsgCaches[msgName];
|
||||
}
|
||||
this.$emit('on-progress', pseudo);
|
||||
|
||||
const controller = new AbortController();
|
||||
const task = {controller, uploadId: ''};
|
||||
this.chunkedTasks[pseudo.uid] = task;
|
||||
|
||||
chunkedUpload({
|
||||
file: rawFile,
|
||||
scene: 'dialog_file',
|
||||
sceneParams: {
|
||||
dialog_ids: [this.dialogId],
|
||||
reply_id: this.quoteData?.id || 0,
|
||||
},
|
||||
onProgress: percent => {
|
||||
pseudo.percentage = percent;
|
||||
this.$emit('on-progress', pseudo);
|
||||
},
|
||||
onStart: uploadId => { task.uploadId = uploadId; },
|
||||
signal: controller.signal,
|
||||
}).then(data => {
|
||||
pseudo.status = 'finished';
|
||||
pseudo.percentage = 100;
|
||||
pseudo.data = data;
|
||||
this.$emit('on-success', pseudo);
|
||||
if (data && data.task_id) {
|
||||
this.$store.dispatch("getTaskFiles", data.task_id);
|
||||
}
|
||||
}).catch(err => {
|
||||
// 用户主动取消:abort 在 axios 抛 CanceledError(message='canceled'),calcMd5 抛 Error('aborted');signal.aborted 兜底
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const msg = (err && err.message) || $L('发送失败');
|
||||
$A.modalWarning({
|
||||
title: '发送失败',
|
||||
content: '文件 ' + rawFile.name + ' 发送失败,' + msg,
|
||||
});
|
||||
this.$emit('on-error', pseudo);
|
||||
}).finally(() => {
|
||||
delete this.chunkedTasks[pseudo.uid];
|
||||
});
|
||||
},
|
||||
|
||||
handleProgress(event, file) {
|
||||
//上传时
|
||||
if (file.tempId === undefined) {
|
||||
@ -182,6 +261,21 @@ export default {
|
||||
|
||||
cancel(uid) {
|
||||
//取消上传
|
||||
const task = this.chunkedTasks[uid];
|
||||
if (task) {
|
||||
// 分片路径:abort 在飞请求 + 通知后端清掉 Redis/分片目录。
|
||||
// 返回 true 后由 DialogWrapper.onCancelSend 调 forgetTempMsg 移除临时气泡。
|
||||
task.controller.abort();
|
||||
if (task.uploadId) {
|
||||
this.$store.dispatch('call', {
|
||||
url: 'upload/cancel',
|
||||
data: {upload_id: task.uploadId},
|
||||
method: 'post',
|
||||
}).catch(() => { /* 后端有 24h TTL 兜底,失败可忽略 */ });
|
||||
}
|
||||
delete this.chunkedTasks[uid];
|
||||
return true;
|
||||
}
|
||||
return this.$refs.upload.cancel(uid);
|
||||
},
|
||||
|
||||
|
||||
@ -282,7 +282,7 @@
|
||||
</AutoTip>
|
||||
<AutoTip v-if="item.status === 'finished' && item.response && item.response.ret !== 1" class="file-error">{{item.response.msg}}</AutoTip>
|
||||
<Progress v-else :percent="uploadPercentageParse(item.percentage)" :stroke-width="5" />
|
||||
<Icon class="file-close" type="ios-close-circle-outline" @click.stop="uploadList.splice(index, 1)"/>
|
||||
<Icon class="file-close" type="ios-close-circle-outline" @click.stop="uploadAbort(item, index)"/>
|
||||
</li>
|
||||
</ul>
|
||||
<Icon class="close" type="md-close" @click="uploadShow=false"/>
|
||||
@ -492,6 +492,7 @@ import longpress from "../../directives/longpress";
|
||||
import UserSelect from "../../components/UserSelect.vue";
|
||||
import UserAvatarTip from "../../components/UserAvatar/tip.vue";
|
||||
import Forwarder from "./components/Forwarder/index.vue";
|
||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../../store/chunkedUpload";
|
||||
|
||||
const FilePreview = () => import('./components/FilePreview');
|
||||
const FileContent = () => import('./components/FileContent');
|
||||
@ -2374,6 +2375,21 @@ export default {
|
||||
this.$refs.dirUpload.clearFiles();
|
||||
},
|
||||
|
||||
uploadAbort(item, index) {
|
||||
// 进行中的分片任务:abort 在飞请求 + 通知后端清理;非分片(老接口/已完成)直接从列表移除
|
||||
if (item && item.status === 'uploading' && item._chunkCtrl) {
|
||||
item._chunkCtrl.abort();
|
||||
if (item._chunkUploadId) {
|
||||
this.$store.dispatch('call', {
|
||||
url: 'upload/cancel',
|
||||
data: {upload_id: item._chunkUploadId},
|
||||
method: 'post',
|
||||
}).catch(() => { /* 后端 24h TTL 兜底 */ });
|
||||
}
|
||||
}
|
||||
this.uploadList.splice(index, 1);
|
||||
},
|
||||
|
||||
uploadPercentageParse(val) {
|
||||
return parseInt(val, 10);
|
||||
},
|
||||
@ -2429,6 +2445,11 @@ export default {
|
||||
handleBeforeUpload(file) {
|
||||
//上传前判断
|
||||
this.uploadCover = false
|
||||
// ≥ 10MB 自动走分片上传(拦截 iview 原生 action POST)
|
||||
if (file.size >= CHUNK_THRESHOLD) {
|
||||
this.handleChunkedUpload(file);
|
||||
return false;
|
||||
}
|
||||
if (this.uploadDir) {
|
||||
this.handleUploadNext();
|
||||
return true;
|
||||
@ -2461,6 +2482,77 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
handleChunkedUpload(file) {
|
||||
// 文件柜大文件分片上传:保留与小文件路径一致的"覆盖确认"语义,
|
||||
// 进度/失败 UI 复用现有 uploadList + Progress 数据流。
|
||||
const hasSame = !this.uploadDir
|
||||
&& this.fileList.findIndex(item => $A.getFileName(item) === file.name) > -1;
|
||||
const launch = (overwrite) => {
|
||||
this.handleUploadNext();
|
||||
const controller = new AbortController();
|
||||
const pseudo = {
|
||||
uid: 'chunked-' + Date.now() + '-' + Math.random().toString(36).slice(2),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'uploading',
|
||||
percentage: 0,
|
||||
response: null,
|
||||
_chunkCtrl: controller,
|
||||
_chunkUploadId: '',
|
||||
};
|
||||
this.uploadList.unshift(pseudo);
|
||||
this.uploadIng++;
|
||||
const sceneParams = {
|
||||
pid: this.pid,
|
||||
webkit_relative_path: file.webkitRelativePath || file.name,
|
||||
overwrite: !!overwrite,
|
||||
};
|
||||
chunkedUpload({
|
||||
file,
|
||||
scene: 'file_cabinet',
|
||||
sceneParams,
|
||||
onProgress: percent => { pseudo.percentage = percent; },
|
||||
onStart: uploadId => { pseudo._chunkUploadId = uploadId; },
|
||||
signal: controller.signal,
|
||||
}).then(data => {
|
||||
pseudo.status = 'finished';
|
||||
pseudo.percentage = 100;
|
||||
pseudo.response = {ret: 1, data, msg: data && data.msg || 'success'};
|
||||
this.uploadIng--;
|
||||
// merge 返回 addItem 数组;秒传返回 {done, instant, addItem}
|
||||
const addItem = (data && data.addItem) ? data.addItem : data;
|
||||
this.$store.dispatch("saveFile", addItem);
|
||||
}).catch(err => {
|
||||
this.uploadIng--;
|
||||
if (controller.signal.aborted) {
|
||||
// 用户点 close 取消:uploadAbort 已把条目从 uploadList 摘除,这里只静默退出
|
||||
return;
|
||||
}
|
||||
pseudo.status = 'finished';
|
||||
const msg = (err && err.message) || $L('上传失败');
|
||||
pseudo.response = {ret: 0, msg};
|
||||
$A.modalWarning({
|
||||
title: '上传失败',
|
||||
content: '文件 ' + file.name + ' 上传失败,' + msg,
|
||||
});
|
||||
});
|
||||
};
|
||||
if (hasSame) {
|
||||
$A.modalConfirm({
|
||||
wait: true,
|
||||
title: '文件已存在',
|
||||
content: '文件 ' + file.name + ' 已存在,是否替换?',
|
||||
cancelText: '保留两者',
|
||||
okText: '替换',
|
||||
closable: true,
|
||||
onOk: () => launch(true),
|
||||
onCancel: (isButton) => { if (isButton) launch(false); },
|
||||
});
|
||||
} else {
|
||||
launch(false);
|
||||
}
|
||||
},
|
||||
|
||||
handleUploadNext() {
|
||||
this.uploadShow = true;
|
||||
this.packShow = false;
|
||||
|
||||
@ -316,7 +316,7 @@
|
||||
</FormItem>
|
||||
<FormItem :label="$L('文件上传限制')" prop="fileUploadLimit">
|
||||
<div style="width: 220px;">
|
||||
<Input type="number" number v-model="formDatum.file_upload_limit" :placeholder="$L('默认不限制')">
|
||||
<Input type="number" number v-model="formDatum.file_upload_limit" :placeholder="$L('默认 1G')">
|
||||
<template #append>
|
||||
<span>MB</span>
|
||||
</template>
|
||||
|
||||
225
resources/assets/js/store/chunkedUpload.js
vendored
Normal file
225
resources/assets/js/store/chunkedUpload.js
vendored
Normal file
@ -0,0 +1,225 @@
|
||||
// 分片上传 wrapper。调用方按 file.size >= CHUNK_THRESHOLD 自行决定走老接口还是这里。
|
||||
// 后端 3 接口:upload/init → upload/chunk × N → upload/merge
|
||||
|
||||
import axios from 'axios'
|
||||
import SparkMD5 from 'spark-md5'
|
||||
import store from './index'
|
||||
import { languageName } from '../language'
|
||||
|
||||
export const CHUNK_THRESHOLD = 10 * 1024 * 1024
|
||||
export const CHUNK_SIZE = 5 * 1024 * 1024 // 必须与后端 ChunkUpload::CHUNK_SIZE 一致
|
||||
const CONCURRENCY = 3
|
||||
const RETRY_MAX = 3
|
||||
const LOCAL_INDEX_PREFIX = 'chunked_upload:'
|
||||
const LOCAL_INDEX_TTL = 24 * 3600 * 1000
|
||||
|
||||
// 业务错误(非网络),命中立即抛,不浪费重试次数
|
||||
const NON_RETRYABLE_MSGS = new Set([
|
||||
'上传会话不存在或已过期',
|
||||
'上传会话归属错误',
|
||||
'分片序号超出范围',
|
||||
'分片大小不符合预期',
|
||||
'末尾分片大小不符合预期',
|
||||
])
|
||||
|
||||
// hashing 0-5 / uploading 5-95 / merging 95-100
|
||||
function mapProgress(stage, percent) {
|
||||
if (stage === 'hashing') return Math.round(percent * 0.05)
|
||||
if (stage === 'uploading') return 5 + Math.round(percent * 0.9)
|
||||
return 95 + Math.round(percent * 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {File} opts.file
|
||||
* @param {'file_cabinet'|'dialog_file'|'image'|'generic_file'} opts.scene
|
||||
* @param {Object} [opts.sceneParams] 透传到后端 merge 阶段
|
||||
* @param {(percent:number, stage:'hashing'|'uploading'|'merging') => void} [opts.onProgress]
|
||||
* @param {(uploadId:string) => void} [opts.onStart] init 拿到 upload_id 后回调,秒传命中不会触发;调用方可据此实现取消
|
||||
* @param {AbortSignal} [opts.signal]
|
||||
* @returns {Promise<Object>} merge 返回 data(与该 scene 老接口对齐)
|
||||
*/
|
||||
export async function chunkedUpload({ file, scene, sceneParams = {}, onProgress, onStart, signal }) {
|
||||
const cb = typeof onProgress === 'function' ? onProgress : () => {}
|
||||
const progress = (stage, pct) => cb(mapProgress(stage, pct), stage)
|
||||
|
||||
progress('hashing', 0)
|
||||
const hash = await calcMd5(file, signal, (loaded, total) => {
|
||||
progress('hashing', Math.min(99, Math.round((loaded / total) * 100)))
|
||||
})
|
||||
progress('hashing', 100)
|
||||
if (signal && signal.aborted) throw new Error('aborted')
|
||||
|
||||
const initResp = await callJson('upload/init', {
|
||||
hash,
|
||||
size: file.size,
|
||||
name: file.name,
|
||||
scene,
|
||||
scene_params: sceneParams,
|
||||
}, signal)
|
||||
if (initResp.ret !== 1) {
|
||||
throw new Error(initResp.msg || '上传初始化失败')
|
||||
}
|
||||
if (initResp.data && initResp.data.done) {
|
||||
progress('uploading', 100)
|
||||
progress('merging', 100)
|
||||
return initResp.data
|
||||
}
|
||||
const { upload_id, chunk_size, chunk_count, received } = initResp.data
|
||||
saveLocalIndex(hash, upload_id, scene)
|
||||
if (typeof onStart === 'function') {
|
||||
try { onStart(upload_id) } catch (_e) { /* 调用方异常不阻断上传 */ }
|
||||
}
|
||||
|
||||
const receivedSet = new Set((received || []).map(Number))
|
||||
const todo = []
|
||||
for (let i = 0; i < chunk_count; i++) {
|
||||
if (!receivedSet.has(i)) todo.push(i)
|
||||
}
|
||||
let uploadedCount = receivedSet.size
|
||||
const updateUploading = () => {
|
||||
progress('uploading', Math.min(99, Math.round((uploadedCount / chunk_count) * 100)))
|
||||
}
|
||||
updateUploading()
|
||||
|
||||
// cursor 协作:每个 worker 抓下一个 index,避免分片到 worker 的预分配在失败时空闲
|
||||
let cursor = 0
|
||||
let firstError = null
|
||||
const workOne = async () => {
|
||||
while (true) {
|
||||
if (firstError) return
|
||||
if (signal && signal.aborted) {
|
||||
firstError = new Error('aborted')
|
||||
return
|
||||
}
|
||||
const myCursor = cursor++
|
||||
if (myCursor >= todo.length) return
|
||||
const idx = todo[myCursor]
|
||||
const start = idx * chunk_size
|
||||
const end = Math.min(start + chunk_size, file.size)
|
||||
const blob = file.slice(start, end)
|
||||
try {
|
||||
await uploadChunkWithRetry(upload_id, idx, blob, signal)
|
||||
uploadedCount++
|
||||
updateUploading()
|
||||
} catch (e) {
|
||||
if (!firstError) firstError = e
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const workerCount = Math.min(CONCURRENCY, todo.length || 1)
|
||||
await Promise.all(Array.from({ length: workerCount }, () => workOne()))
|
||||
if (firstError) throw firstError
|
||||
progress('uploading', 100)
|
||||
|
||||
progress('merging', 0)
|
||||
const mergeResp = await callJson('upload/merge', { upload_id }, signal)
|
||||
if (mergeResp.ret !== 1) {
|
||||
throw new Error(mergeResp.msg || '合并失败')
|
||||
}
|
||||
progress('merging', 100)
|
||||
clearLocalIndex(hash)
|
||||
return mergeResp.data
|
||||
}
|
||||
|
||||
async function uploadChunkWithRetry(uploadId, index, blob, signal) {
|
||||
let lastErr
|
||||
for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
|
||||
if (signal && signal.aborted) throw new Error('aborted')
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('upload_id', uploadId)
|
||||
fd.append('index', String(index))
|
||||
fd.append('blob', blob)
|
||||
const resp = await axios.post(window.$A.apiUrl('upload/chunk'), fd, {
|
||||
headers: buildHeaders(),
|
||||
signal,
|
||||
})
|
||||
if (resp.data && resp.data.ret === 1) return resp.data.data
|
||||
lastErr = new Error((resp.data && resp.data.msg) || '分片上传失败')
|
||||
} catch (e) {
|
||||
if (signal && signal.aborted) throw e
|
||||
lastErr = e
|
||||
}
|
||||
if (NON_RETRYABLE_MSGS.has(lastErr.message)) throw lastErr
|
||||
// 指数退避 500ms / 1s / 2s
|
||||
if (attempt < RETRY_MAX) {
|
||||
await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt)))
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('分片重试耗尽')
|
||||
}
|
||||
|
||||
async function callJson(path, data, signal) {
|
||||
const resp = await axios.post(window.$A.apiUrl(path), data, {
|
||||
headers: {
|
||||
...buildHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
function buildHeaders() {
|
||||
return {
|
||||
token: store.state.userToken,
|
||||
language: languageName,
|
||||
platform: window.$A.Platform,
|
||||
version: (window.systemInfo && window.systemInfo.version) || '0.0.1',
|
||||
fd: window.$A.getSessionStorageString('userWsFd'),
|
||||
}
|
||||
}
|
||||
|
||||
// 主线程算 hash:Vite ?worker 在 dev/反代下 worker URL 走 document.baseURI → 主程序返回 HTML → MIME 报错。
|
||||
// 实测 12MB ~200ms / 120MB ~2s / 1G ~20s,每片 setTimeout(0) 让渡保持 UI 刷新。
|
||||
async function calcMd5(file, signal, onProg) {
|
||||
const spark = new SparkMD5.ArrayBuffer()
|
||||
const READ = 2 * 1024 * 1024
|
||||
for (let offset = 0; offset < file.size; offset += READ) {
|
||||
if (signal && signal.aborted) throw new Error('aborted')
|
||||
const end = Math.min(offset + READ, file.size)
|
||||
const buf = await file.slice(offset, end).arrayBuffer()
|
||||
spark.append(buf)
|
||||
onProg(end, file.size)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
}
|
||||
return spark.end().toLowerCase()
|
||||
}
|
||||
|
||||
function saveLocalIndex(hash, uploadId, scene) {
|
||||
try {
|
||||
const data = { upload_id: uploadId, scene, t: Date.now() }
|
||||
localStorage.setItem(LOCAL_INDEX_PREFIX + hash, JSON.stringify(data))
|
||||
} catch (_e) { /* localStorage 容量满 */ }
|
||||
}
|
||||
|
||||
function clearLocalIndex(hash) {
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_INDEX_PREFIX + hash)
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理本机过期的续传索引(可在 store 启动时调一次)。
|
||||
*/
|
||||
export function purgeLocalIndex() {
|
||||
try {
|
||||
const now = Date.now()
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i)
|
||||
if (!key || !key.startsWith(LOCAL_INDEX_PREFIX)) continue
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(key) || '{}')
|
||||
if (!v.t || now - v.t > LOCAL_INDEX_TTL) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
} catch (_e) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
|
||||
export default chunkedUpload
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:307
|
||||
接口总数:311
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -315,6 +315,15 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| api/file/link | link() | get | 获取链接 |
|
||||
| api/file/download/pack | download__pack() | get | 打包文件 |
|
||||
|
||||
## upload(UploadController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/upload/init | init() | post | 启动上传会话 |
|
||||
| api/upload/chunk | chunk() | post | 上传一个分片 |
|
||||
| api/upload/merge | merge() | post | 合并分片并入库 |
|
||||
| api/upload/cancel | cancel() | post | 取消上传会话 |
|
||||
|
||||
## report(ReportController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
|
||||
@ -15,6 +15,7 @@ use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ComplaintController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\AppsController;
|
||||
use App\Http\Controllers\Api\UploadController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -50,6 +51,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
// 文件
|
||||
Route::any('file/{method}', FileController::class);
|
||||
Route::any('file/{method}/{action}', FileController::class);
|
||||
// 分片上传
|
||||
Route::any('upload/{method}', UploadController::class);
|
||||
Route::any('upload/{method}/{action}', UploadController::class);
|
||||
// 报告
|
||||
Route::any('report/{method}', ReportController::class);
|
||||
Route::any('report/{method}/{action}', ReportController::class);
|
||||
|
||||
40
tests/manual/chunk_cleanup_smoke.php
Normal file
40
tests/manual/chunk_cleanup_smoke.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* 验证 DeleteTmpTask::tmp_chunks 清理逻辑。
|
||||
* docker exec dootask-php-3bed84 php /var/www/tests/manual/chunk_cleanup_smoke.php
|
||||
*/
|
||||
require '/var/www/vendor/autoload.php';
|
||||
$app = require_once '/var/www/bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
use App\Tasks\DeleteTmpTask;
|
||||
|
||||
function out(string $m): void { echo "[" . date('H:i:s') . "] $m\n"; }
|
||||
function fail(string $m): void { out("FAIL: $m"); exit(1); }
|
||||
|
||||
$root = public_path('uploads/tmp/chunks');
|
||||
@mkdir("$root/9999/old_session", 0775, true);
|
||||
@mkdir("$root/9999/new_session", 0775, true);
|
||||
file_put_contents("$root/9999/old_session/0", 'old');
|
||||
file_put_contents("$root/9999/new_session/0", 'new');
|
||||
|
||||
// 把 old_session 的 mtime 改成 25 小时前
|
||||
$past = time() - 3600 * 25;
|
||||
touch("$root/9999/old_session", $past);
|
||||
touch("$root/9999/old_session/0", $past);
|
||||
|
||||
$task = new DeleteTmpTask('tmp_chunks', 24);
|
||||
$task->start();
|
||||
|
||||
if (is_dir("$root/9999/old_session")) {
|
||||
fail('old_session 应被清理但仍在');
|
||||
}
|
||||
if (!is_dir("$root/9999/new_session")) {
|
||||
fail('new_session 不应被清理却被删');
|
||||
}
|
||||
out('OK: old_session 清掉 / new_session 保留');
|
||||
|
||||
// 清理测试残留
|
||||
exec("rm -rf " . escapeshellarg("$root/9999"));
|
||||
out('=== 通过 ===');
|
||||
203
tests/manual/chunk_upload_smoke.php
Normal file
203
tests/manual/chunk_upload_smoke.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* 手工烟雾测试:ChunkUpload 端到端(跳过 HTTP,直接调模块)。
|
||||
*
|
||||
* 在 dootask-php-3bed84 容器内执行:
|
||||
* docker exec dootask-php-3bed84 php /var/www/tests/manual/chunk_upload_smoke.php
|
||||
*
|
||||
* 步骤:新文件分片上传 → 秒传命中 → 续传场景 → 清理
|
||||
*/
|
||||
require '/var/www/vendor/autoload.php';
|
||||
$app = require_once '/var/www/bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
use App\Models\File as FileModel;
|
||||
use App\Models\User;
|
||||
use App\Module\ChunkUpload;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
function out(string $msg): void
|
||||
{
|
||||
echo "[" . date('H:i:s') . "] " . $msg . "\n";
|
||||
}
|
||||
|
||||
function fail(string $msg): void
|
||||
{
|
||||
out("FAIL: $msg");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 1) 找一个测试用户
|
||||
$user = User::orderBy('userid')->first();
|
||||
if (!$user) {
|
||||
fail('找不到任何用户');
|
||||
}
|
||||
out("使用用户 userid={$user->userid} email={$user->email}");
|
||||
|
||||
// 2) 准备 12MB 测试文件
|
||||
$srcPath = '/tmp/cu_smoke_src.bin';
|
||||
$size = 12 * 1024 * 1024;
|
||||
$fp = fopen($srcPath, 'wb');
|
||||
for ($i = 0; $i < $size / 4096; $i++) {
|
||||
fwrite($fp, str_repeat(chr($i % 256), 4096));
|
||||
}
|
||||
fclose($fp);
|
||||
$hash = md5_file($srcPath);
|
||||
out("准备文件: $srcPath size=$size hash=$hash");
|
||||
|
||||
// 3) 切分为 5MB 分片
|
||||
$chunkSize = ChunkUpload::CHUNK_SIZE;
|
||||
$chunkCount = intval(ceil($size / $chunkSize));
|
||||
$chunks = [];
|
||||
$fp = fopen($srcPath, 'rb');
|
||||
for ($i = 0; $i < $chunkCount; $i++) {
|
||||
$partPath = "/tmp/cu_smoke_part_{$i}.bin";
|
||||
$wfp = fopen($partPath, 'wb');
|
||||
$bytesLeft = ($i === $chunkCount - 1) ? ($size - $chunkSize * $i) : $chunkSize;
|
||||
while ($bytesLeft > 0) {
|
||||
$buf = fread($fp, min(65536, $bytesLeft));
|
||||
fwrite($wfp, $buf);
|
||||
$bytesLeft -= strlen($buf);
|
||||
}
|
||||
fclose($wfp);
|
||||
$chunks[$i] = $partPath;
|
||||
}
|
||||
fclose($fp);
|
||||
out("切分: count=$chunkCount");
|
||||
|
||||
function makeBlob(string $path, string $name): UploadedFile
|
||||
{
|
||||
return new UploadedFile($path, $name, null, null, true);
|
||||
}
|
||||
|
||||
// 4) 清理可能的残留
|
||||
$existing = FileModel::whereHash($hash)->whereUserid($user->userid)->forceDelete();
|
||||
out("清理残留 files 记录: $existing 条");
|
||||
Redis::del("upload:hash:{$user->userid}:{$hash}");
|
||||
|
||||
// 5) 找一个根目录 pid=0 作为 file_cabinet 上传位置
|
||||
$sceneParams = [
|
||||
'pid' => 0,
|
||||
'webkit_relative_path' => 'cu_smoke_test.bin',
|
||||
'overwrite' => false,
|
||||
];
|
||||
|
||||
// === 测试 1:完整分片上传 ===
|
||||
out("--- 测试 1: 完整分片上传 ---");
|
||||
$res = ChunkUpload::start($user, [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => 'cu_smoke_test.bin',
|
||||
'scene' => 'file_cabinet',
|
||||
'scene_params' => $sceneParams,
|
||||
]);
|
||||
if ($res['ret'] !== 1) {
|
||||
fail("start 失败: " . json_encode($res));
|
||||
}
|
||||
if (!empty($res['data']['done'])) {
|
||||
fail("首次上传不应直接 done: " . json_encode($res['data']));
|
||||
}
|
||||
$uploadId = $res['data']['upload_id'];
|
||||
out("start ok: upload_id=$uploadId chunk_count={$res['data']['chunk_count']} received=" . json_encode($res['data']['received']));
|
||||
|
||||
// 上传每个分片(每次都重新做 UploadedFile,因为 move 会移走文件)
|
||||
for ($i = 0; $i < $chunkCount; $i++) {
|
||||
$copyPath = $chunks[$i] . '.tx';
|
||||
copy($chunks[$i], $copyPath);
|
||||
$res = ChunkUpload::receive($user, $uploadId, $i, makeBlob($copyPath, "part_$i"));
|
||||
if ($res['ret'] !== 1) {
|
||||
fail("receive[$i] 失败: " . json_encode($res));
|
||||
}
|
||||
out(" receive[$i] ok received=" . json_encode($res['data']['received']));
|
||||
}
|
||||
|
||||
// 合并
|
||||
$res = ChunkUpload::merge($user, $uploadId);
|
||||
if ($res['ret'] !== 1) {
|
||||
fail("merge 失败: " . json_encode($res));
|
||||
}
|
||||
out("merge ok: " . substr(json_encode($res['data']), 0, 200));
|
||||
|
||||
// 验证 files 表
|
||||
$created = FileModel::whereHash($hash)->whereUserid($user->userid)->first();
|
||||
if (!$created) {
|
||||
fail("files 表未找到新记录");
|
||||
}
|
||||
out("files 表 OK: id={$created->id} name={$created->name}.{$created->ext} size={$created->size} hash={$created->hash}");
|
||||
|
||||
// === 测试 2:秒传 ===
|
||||
out("--- 测试 2: 秒传 ---");
|
||||
$res = ChunkUpload::start($user, [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => 'cu_smoke_test.bin',
|
||||
'scene' => 'file_cabinet',
|
||||
'scene_params' => $sceneParams,
|
||||
]);
|
||||
if ($res['ret'] !== 1 || empty($res['data']['done']) || empty($res['data']['instant'])) {
|
||||
fail("秒传未命中: " . json_encode($res));
|
||||
}
|
||||
$instantId = $res['data']['addItem'][0]['id'] ?? 0;
|
||||
out("秒传 OK: 新建 file.id=$instantId");
|
||||
|
||||
// === 测试 3:续传 ===
|
||||
out("--- 测试 3: 续传 ---");
|
||||
// 删全部同 hash 文件(含秒传新建的那条)让 hash 反查失败
|
||||
FileModel::whereHash($hash)->whereUserid($user->userid)->forceDelete();
|
||||
// 启新会话
|
||||
$res = ChunkUpload::start($user, [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => 'cu_smoke_test.bin',
|
||||
'scene' => 'file_cabinet',
|
||||
'scene_params' => $sceneParams,
|
||||
]);
|
||||
$uploadId2 = $res['data']['upload_id'];
|
||||
out("续传场景 start: upload_id=$uploadId2");
|
||||
// 只上传第 0 片
|
||||
copy($chunks[0], $chunks[0] . '.r1');
|
||||
ChunkUpload::receive($user, $uploadId2, 0, makeBlob($chunks[0] . '.r1', 'part_0'));
|
||||
out("先传 1 片,received=" . json_encode([0]));
|
||||
// 再次 start 同 hash → 应返回 received=[0]
|
||||
$res = ChunkUpload::start($user, [
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'name' => 'cu_smoke_test.bin',
|
||||
'scene' => 'file_cabinet',
|
||||
'scene_params' => $sceneParams,
|
||||
]);
|
||||
if (!empty($res['data']['done'])) {
|
||||
fail("续传场景不应 done: " . json_encode($res));
|
||||
}
|
||||
if ($res['data']['upload_id'] !== $uploadId2) {
|
||||
fail("续传应复用 upload_id: 期望 $uploadId2, 得到 " . $res['data']['upload_id']);
|
||||
}
|
||||
if ($res['data']['received'] !== [0]) {
|
||||
fail("续传 received 应为 [0]: " . json_encode($res['data']['received']));
|
||||
}
|
||||
out("续传命中 OK,received=" . json_encode($res['data']['received']));
|
||||
// 补传剩余分片
|
||||
for ($i = 1; $i < $chunkCount; $i++) {
|
||||
copy($chunks[$i], $chunks[$i] . '.r2');
|
||||
ChunkUpload::receive($user, $uploadId2, $i, makeBlob($chunks[$i] . '.r2', "part_$i"));
|
||||
}
|
||||
$res = ChunkUpload::merge($user, $uploadId2);
|
||||
if ($res['ret'] !== 1) {
|
||||
fail("续传 merge 失败: " . json_encode($res));
|
||||
}
|
||||
out("续传 merge OK");
|
||||
|
||||
// 清理
|
||||
$final = FileModel::whereHash($hash)->whereUserid($user->userid)->first();
|
||||
if ($final) {
|
||||
out("清理: 删除 files.id={$final->id}");
|
||||
$final->forceDelete();
|
||||
}
|
||||
foreach ($chunks as $p) {
|
||||
@unlink($p);
|
||||
}
|
||||
@unlink($srcPath);
|
||||
|
||||
out("=== 全部通过 ===");
|
||||
Loading…
x
Reference in New Issue
Block a user