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:
kuaifan 2026-06-30 03:54:28 +00:00
parent c04187fe47
commit 184fb27680
23 changed files with 1716 additions and 91 deletions

View 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('已取消');
}
}

View File

@ -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];
});
}

View File

@ -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;
}
}

View File

@ -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
View 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;
/** 状态/反查索引 TTL24h */
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),
];
}
}

View File

@ -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')

View File

@ -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');
});
}
};

View File

@ -1021,3 +1021,22 @@ AI 助手
密钥无效
应用未安装
菜单不存在
源文件不存在
文件 hash 格式错误
文件大小无效
文件超过系统支持的最大尺寸
文件名不能为空
不支持的上传场景
上传会话不存在或已过期
上传会话归属错误
分片序号超出范围
分片数据无效
分片大小不符合预期
末尾分片大小不符合预期
分片读取失败:(*)
分片不完整,无法合并
无法创建合并文件
文件校验失败,请重试
scene 暂未实现:(*)
upload_id 不能为空
合并繁忙,请稍后再试

View File

@ -1434,6 +1434,16 @@ License Key
私聊禁言
群聊禁言
默认不限制
默认 1G
准备中...
上传中...
合并中...
上传已暂停
网络异常,重试中...
上传初始化失败
分片上传失败
合并失败
分片重试耗尽
开放:所有人都可以在全员群组发言。
开放:所有人都可以相互发起个人聊天。
开放:允许个人群组聊天发言。

View File

@ -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",

View File

@ -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]] 给管理员看

View File

@ -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 反查可兜底
- 不支持移动端后台续传(应用切到后台可能中断)

View File

@ -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 MB1G兜底**;超过则报错 `文件大小超限最大限制N KB`
## 与分片上传的关系
- 前端 ≥ 10MB 自动走分片上传(单分片 5MB跟系统设置的"单文件上限"是两件事
- 提高 `file_upload_limit` 后,分片上传可以突破老的 1G 限制(每个分片远小于底层 PHP/Nginx/Swoole 限制)
- 例如填 `5120`5G5G 视频可通过分片上传完成(受磁盘 / 内存等部署能力约束)
## 字段默认值
| 字段 | 默认 | 单位 |
|---|---|---|
| `file_upload_limit` | 空(不限制 | MB |
| `file_upload_limit` | 空(按 1G 兜底 | MB |
## 操作步骤
1. 进入「系统设置」→「系统设置」→「其他设置」

View File

@ -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()) {

View File

@ -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>

View File

@ -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);
},

View File

@ -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;

View File

@ -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>

View 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'),
}
}
// 主线程算 hashVite ?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

View File

@ -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 | 打包文件 |
## uploadUploadController
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/upload/init | init() | post | 启动上传会话 |
| api/upload/chunk | chunk() | post | 上传一个分片 |
| api/upload/merge | merge() | post | 合并分片并入库 |
| api/upload/cancel | cancel() | post | 取消上传会话 |
## reportReportController
| URL | 方法名 | HTTP | 说明 |

View File

@ -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);

View 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('=== 通过 ===');

View 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("续传命中 OKreceived=" . 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("=== 全部通过 ===");