feat(extensions): 新增系统工具页面与前台投稿分片上传

This commit is contained in:
2026-10-06 11:09:05 +08:00
parent 613b8a57a3
commit b75bf197a3
25 changed files with 1982 additions and 58 deletions
+24 -7
View File
@@ -17,6 +17,7 @@ class FrontSubmit {
use FrontSubmitPay;
use FrontSubmitFeatured;
use FrontSubmitMedia;
use FrontSubmitChunk;
private static $instance = null;
@@ -93,6 +94,8 @@ class FrontSubmit {
],
],
]);
$this->register_chunk_routes();
}
public function can_upload_attachment(\WP_REST_Request $request): bool {
@@ -128,15 +131,26 @@ class FrontSubmit {
}
public function rest_upload_attachment(\WP_REST_Request $request) {
$kind = (string) $request->get_param('kind');
$user_id = get_current_user_id();
$files = $request->get_file_params();
$file = $files['file'] ?? null;
$kind = (string) $request->get_param('kind');
$files = $request->get_file_params();
$file = $files['file'] ?? null;
if (!is_array($file)) {
return new \WP_Error('aksrt_upload_missing_file', __('请选择要上传的文件。', 'aksrt'), ['status' => 400]);
}
return $this->persist_uploaded_file($kind, $file, false);
}
/**
* 校验并把一个上传文件落库为附件;整传与分片合并共用同一条管线。
*
* @param string $kind image|video|resource
* @param array $file $_FILES 风格数组(name/tmp_name/size/error)
* @param bool $sideload true 时文件不是 HTTP 直传(分片合并产物),用 sideload 动作保存
* @return \WP_REST_Response|\WP_Error
*/
public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) {
$max_size = self::upload_limit_bytes($kind);
$upload_error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
@@ -156,7 +170,7 @@ class FrontSubmit {
return new \WP_Error('aksrt_upload_failed', __('服务器无法保存上传文件。', 'aksrt'), ['status' => 500]);
}
$size = (int) ($file['size'] ?? 0);
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > $max_size) {
return new \WP_Error(
@@ -197,15 +211,18 @@ class FrontSubmit {
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// 合并产物不是 HTTP 直传文件,走 sideload 动作(copy 而非 move_uploaded_file)。
$overrides = $sideload ? ['test_form' => false, 'action' => 'wp_handle_sideload'] : ['test_form' => false];
$_FILES['aksrt_front_submit_file'] = $file;
$attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], ['test_form' => false]);
$attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], $overrides);
unset($_FILES['aksrt_front_submit_file']);
if (is_wp_error($attachment_id)) {
return new \WP_Error('aksrt_upload_failed', $attachment_id->get_error_message(), ['status' => 400]);
}
$attachment_id = (int) $attachment_id;
wp_update_post(['ID' => $attachment_id, 'post_author' => $user_id]);
wp_update_post(['ID' => $attachment_id, 'post_author' => get_current_user_id()]);
update_post_meta($attachment_id, '_aksrt_front_submit_media', $kind);
$attachment = $this->prepare_attachment_response($attachment_id);
+278
View File
@@ -0,0 +1,278 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
/**
* 前台大文件分片上传(后台「扩展&增强 → 系统工具」的 split_upload_s / split_minimum_size)。
*
* 行为对齐参考主题 action/media.php 的 user_split_upload / user_split_upload_merge /
* user_split_uploaded_chunk 协议:2MB 一片、断点续传(已传分片按序跳过)、合并产物
* 经 sideload 动作重新走 persist_uploaded_file() 标准校验与落库管线。
*/
trait FrontSubmitChunk {
/** 每个分片的固定大小(参考主题 split_chunk_size = 2MB)。 */
const CHUNK_SIZE_BYTES = 2 * 1048576;
public function register_chunk_routes(): void {
$kind_arg = [
'validate_callback' => static function ($value): bool {
return in_array((string) $value, ['image', 'video', 'resource'], true);
},
];
register_rest_route('aksrt/v1', '/front-submit/upload-chunk/(?P<kind>image|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/(?P<kind>image|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/(?P<kind>image|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, "<?php\n// Silence is golden.\n");
}
return $temp_dir;
}
/** 清理超过 48 小时的遗留分片。 */
private function chunk_temp_cleanup(string $temp_dir): void {
$expire = time() - 48 * HOUR_IN_SECONDS;
foreach ((array) glob($temp_dir . '/*.part') as $file) {
if (@filemtime((string) $file) < $expire) {
@unlink((string) $file);
}
}
}
/** 当前 key 的全部分片路径(不含 merge 产物)。 */
private function chunk_part_paths(string $temp_dir, string $key): array {
$paths = [];
foreach ((array) glob($temp_dir . '/' . $this->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);
}
}
-2
View File
@@ -213,8 +213,6 @@ trait ScriptsPart1c {
'upload_ext' => implode('|', array_keys(get_allowed_mime_types())),
'user_upload_nonce' => wp_create_nonce('user_upload'),
'post_action_nonce' => wp_create_nonce('post_action'),
'is_split_upload' => false,
'split_minimum_size' => 10,
'translate_config' => [],
'i18n' => [
'load_more' => __('加载更多', 'aksrt'),