static function ($value): bool { return in_array((string) $value, ['image', 'video', 'resource'], true); }, ]; register_rest_route('aksrt/v1', '/front-submit/upload-chunk/(?Pimage|video|resource)', [ 'methods' => 'POST', 'callback' => [$this, 'rest_upload_chunk'], 'permission_callback' => [$this, 'can_upload_attachment'], 'args' => [ 'kind' => $kind_arg, 'chunk' => ['default' => 0, 'sanitize_callback' => 'absint'], 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], 'size' => ['default' => 0, 'sanitize_callback' => 'absint'], ], ]); register_rest_route('aksrt/v1', '/front-submit/upload-merge/(?Pimage|video|resource)', [ 'methods' => 'POST', 'callback' => [$this, 'rest_upload_chunk_merge'], 'permission_callback' => [$this, 'can_upload_attachment'], 'args' => [ 'kind' => $kind_arg, 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], 'size' => ['default' => 0, 'sanitize_callback' => 'absint'], ], ]); register_rest_route('aksrt/v1', '/front-submit/upload-status/(?Pimage|video|resource)', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_upload_chunk_status'], 'permission_callback' => [$this, 'can_upload_attachment'], 'args' => [ 'kind' => $kind_arg, 'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'], 'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'], ], ]); } /** * 上传单个分片;index 为 0 基,同时累加校验总大小不超上限。 */ public function rest_upload_chunk(\WP_REST_Request $request) { $file = $request->get_file_params()['file'] ?? null; if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return new \WP_Error('aksrt_chunk_missing_file', __('上传信息错误,请重新选择文件', 'aksrt'), ['status' => 400]); } $params = $this->chunk_params($request, true); if (is_wp_error($params)) { return $params; } $max_size = self::upload_limit_bytes($params['kind']); if ($params['size'] <= 0 || $params['size'] > $max_size) { return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); } $chunk_bytes = (int) @filesize((string) $file['tmp_name']); if ($chunk_bytes <= 0 || $chunk_bytes > self::CHUNK_SIZE_BYTES) { return new \WP_Error('aksrt_chunk_invalid_size', __('分片大小异常,请重新上传', 'aksrt'), ['status' => 400]); } $temp_dir = $this->chunk_temp_dir(); if ($params['chunk'] === 0) { $this->chunk_temp_cleanup($temp_dir); } $existing = $this->chunk_part_paths($temp_dir, $params['key']); $uploaded = 0; foreach ($existing as $path) { $uploaded += (int) @filesize($path); } if ($uploaded + $chunk_bytes > $max_size) { return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); } $target = $temp_dir . '/' . $this->chunk_part_name($params['key'], $params['chunk']); $moved = @move_uploaded_file((string) $file['tmp_name'], $target); if (!$moved) { $moved = @copy((string) $file['tmp_name'], $target); @unlink((string) $file['tmp_name']); } if (!$moved) { return new \WP_Error('aksrt_chunk_save_failed', __('分片保存失败,请重新上传', 'aksrt'), ['status' => 500]); } return new \WP_REST_Response([ 'success' => true, 'chunk' => $params['chunk'], 'chunks' => $params['chunks'], 'uploaded_size' => $uploaded + $chunk_bytes, ], 200); } /** * 断点续传:返回已落盘的分片序号,前端按序跳过。 */ public function rest_upload_chunk_status(\WP_REST_Request $request) { $params = $this->chunk_params($request); if (is_wp_error($params)) { return $params; } $parts = $this->chunk_part_paths($this->chunk_temp_dir(), $params['key']); $uploaded = []; foreach ($parts as $path) { $uploaded[] = $this->chunk_index_from_name((string) $path); } return new \WP_REST_Response([ 'success' => true, 'uploaded' => $uploaded, 'uploaded_count' => count($uploaded), ], 200); } /** * 合并分片并按标准管线落库;成功或失败后都清理分片。 */ public function rest_upload_chunk_merge(\WP_REST_Request $request) { $params = $this->chunk_params($request); if (is_wp_error($params)) { return $params; } $max_size = self::upload_limit_bytes($params['kind']); $temp_dir = $this->chunk_temp_dir(); $merge_path = $temp_dir . '/' . $this->chunk_part_name($params['key'], 'merge'); $fp = @fopen($merge_path, 'wb'); if (!$fp) { return new \WP_Error('aksrt_chunk_merge_failed', __('分片合并失败,请重新上传', 'aksrt'), ['status' => 500]); } $missing = 0; for ($i = 0; $i < $params['chunks']; $i++) { $part = $temp_dir . '/' . $this->chunk_part_name($params['key'], $i); if (!is_file($part)) { $missing++; break; } fwrite($fp, (string) file_get_contents($part)); } fclose($fp); if ($missing) { @unlink($merge_path); return new \WP_Error('aksrt_chunk_missing', __('文件缺少分片,正在重新上传缺失分片', 'aksrt'), ['status' => 409]); } $merged_size = (int) @filesize($merge_path); if ($merged_size <= 0 || ($params['size'] > 0 && $merged_size !== $params['size'])) { @unlink($merge_path); return new \WP_Error('aksrt_chunk_size_mismatch', __('合并后的文件大小校验失败,请重新上传', 'aksrt'), ['status' => 400]); } if ($merged_size > $max_size) { @unlink($merge_path); return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]); } $name = sanitize_file_name($params['name']); $type = (string) (wp_check_filetype_and_ext($merge_path, $name, get_allowed_mime_types())['type'] ?? ''); $result = $this->persist_uploaded_file($params['kind'], [ 'tmp_name' => $merge_path, 'name' => $name, 'size' => $merged_size, 'type' => $type, 'error' => UPLOAD_ERR_OK, ], true); // 无论落库成败都清理本次分片(persist 成功时已 copy 走合并文件,失败时也要清场)。 foreach ($this->chunk_part_paths($temp_dir, $params['key']) as $path) { @unlink($path); } @unlink($merge_path); return $result; } /** 校验并归一分片参数(name/chunks/chunk/size/key)。 */ private function chunk_params(\WP_REST_Request $request, bool $with_index = false) { $name = sanitize_file_name((string) $request->get_param('name')); $chunks = (int) $request->get_param('chunks'); $chunk = (int) $request->get_param('chunk'); if ($name === '' || $chunks < 1 || $chunks > 20480) { return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]); } if ($with_index && ($chunk < 0 || $chunk >= $chunks)) { return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]); } return [ 'kind' => (string) $request->get_param('kind'), 'name' => $name, 'chunks' => $chunks, 'chunk' => $chunk, 'size' => (int) $request->get_param('size'), // 分片按用户隔离,避免同名文件互相覆盖、也防止探测他人分片。 'key' => md5(get_current_blog_id() . '|' . get_current_user_id() . '|' . $name), ]; } /** 分片临时目录(参考主题为 wp-content/zib-temp,带 index.php 保护和 48h 清理)。 */ private function chunk_temp_dir(): string { $temp_dir = (string) apply_filters('aksrt_temp_dir', WP_CONTENT_DIR . '/aksrt-temp'); if (!@is_dir($temp_dir)) { wp_mkdir_p($temp_dir); } $index_file = $temp_dir . '/index.php'; if (!file_exists($index_file)) { @file_put_contents($index_file, "chunk_part_name($key, '*')) as $path) { $path = (string) $path; if (substr($path, -10) === 'merge.part') { continue; } $paths[] = $path; } sort($paths, SORT_NATURAL); return $paths; } private function chunk_part_name(string $key, $index): string { return sprintf('%d-%s-%s.part', get_current_blog_id(), $key, (string) $index); } private function chunk_index_from_name(string $path): int { $base = basename($path, '.part'); $dash = strrpos($base, '-'); return $dash === false ? -1 : (int) substr($base, $dash + 1); } }