From ac3ed9c9565a692687d68299bc8459e62ca182f5 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 27 Oct 2026 21:54:48 +0800 Subject: [PATCH] feat(upload): pending lifecycle for upload-on-select images Mark front-end uploads (comment/ticket/report) as pending, return the attachment id, discard own pending uploads on remove via DELETE /upload/pending/{id}, claim referenced URLs on successful submit, and purge unclaimed uploads older than 24h with an hourly cron. Co-Authored-By: Claude Fable 5 --- assets/js/report.js | 12 +- assets/js/ticket-center-core.js | 16 +- functions.php | 3 + src/Admin/UserController.php | 3 + src/Admin/UserControllerReportOperations.php | 2 + src/Attachment/PendingUploads.php | 167 ++++++++++++++++++ src/Ticket/TicketRest.php | 2 + src/Ticket/TicketRestAdmin.php | 2 + .../pending-upload-contract.test.cjs | 42 +++++ 9 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/Attachment/PendingUploads.php create mode 100644 tests/widget-system/pending-upload-contract.test.cjs diff --git a/assets/js/report.js b/assets/js/report.js index 5bfc3e6d..cff19943 100644 --- a/assets/js/report.js +++ b/assets/js/report.js @@ -202,7 +202,17 @@ remove.className = 'remove'; remove.setAttribute('aria-label', '删除图片'); remove.innerHTML = '×'; - remove.addEventListener('click', function () { item.remove(); }); + var uploadId = result.data && result.data.id; + remove.addEventListener('click', function () { + item.remove(); + // 删除服务端待认领附件;失败由定时任务兜底清理。 + if (uploadId) { + window.fetch(API_ROOT + '/upload/pending/' + encodeURIComponent(uploadId), { + method: 'DELETE', + headers: { 'X-WP-Nonce': NONCE }, + }).catch(function () {}); + } + }); item.appendChild(img); item.appendChild(remove); preview.insertBefore(item, preview.querySelector('.add')); diff --git a/assets/js/ticket-center-core.js b/assets/js/ticket-center-core.js index 16558452..aa9c6f37 100644 --- a/assets/js/ticket-center-core.js +++ b/assets/js/ticket-center-core.js @@ -246,6 +246,16 @@ syncUploader(root); } + // × 移除即删除服务端待认领附件(仅本人、未提交的);失败无需打扰用户,定时任务兜底清理。 + function discardUpload(id) { + if (!id || !/^\d+$/.test(id)) return; + fetch(API_ROOT + '/upload/pending/' + id, { + method: 'DELETE', + headers: { 'X-WP-Nonce': NONCE }, + credentials: 'same-origin', + }).catch(function () {}); + } + function uploadOne(root, holder, file) { var tile = document.createElement('div'); tile.className = 'aksrt-ticket-image is-uploading'; @@ -264,6 +274,8 @@ if (!url) return Promise.reject(result); tile.classList.remove('is-uploading'); tile.setAttribute('data-aksrt-ticket-image', url); + var uploadId = result.data && result.data.id; + if (uploadId) tile.setAttribute('data-aksrt-ticket-image-id', String(uploadId)); tile.innerHTML = '' + ''; @@ -295,7 +307,9 @@ var remove = event.target.closest('[data-aksrt-ticket-image-remove]'); if (remove) { var root = remove.closest('form'); - remove.closest('.aksrt-ticket-image').remove(); + var tile = remove.closest('.aksrt-ticket-image'); + discardUpload(tile.getAttribute('data-aksrt-ticket-image-id')); + tile.remove(); syncUploader(root); return; } diff --git a/functions.php b/functions.php index 45f5d4b9..1ca47ec5 100644 --- a/functions.php +++ b/functions.php @@ -313,6 +313,9 @@ if (class_exists('\AKSRT\Core\FrontSubmit')) { if (class_exists('\AKSRT\Attachment\UploadLimits')) { \AKSRT\Attachment\UploadLimits::get_instance()->init(); } +if (class_exists('\AKSRT\Attachment\PendingUploads')) { + \AKSRT\Attachment\PendingUploads::get_instance()->init(); +} if (class_exists('\AKSRT\Payment\Order')) { \AKSRT\Payment\Order::get_instance()->init(); } diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index 8c3f747c..c544f4da 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -612,9 +612,12 @@ class UserController extends ControllerBase { wp_delete_attachment((int) $attachment_id, true); return $this->error(__('上传失败', 'aksrt'), 500); } + // 选图即上传:提交成功前处于待认领状态,× 移除或超时未提交会被清理。 + \AKSRT\Attachment\PendingUploads::mark((int) $attachment_id); return $this->success([ 'message' => __('图片上传成功', 'aksrt'), 'data' => [ + 'id' => (int) $attachment_id, 'url' => $url, ], ]); diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php index 668bdb18..d4528e20 100644 --- a/src/Admin/UserControllerReportOperations.php +++ b/src/Admin/UserControllerReportOperations.php @@ -450,6 +450,8 @@ trait UserControllerReportOperations { ); if ($report_id > 0) { + // 举证图片以 URL 拼入举报内容,提交成功即认领。 + \AKSRT\Attachment\PendingUploads::claim_text($reason, $reporter_id); return $this->success([ 'message' => __('举报已提交,我们会尽快处理', 'aksrt'), 'data' => ['report_id' => $report_id], diff --git a/src/Attachment/PendingUploads.php b/src/Attachment/PendingUploads.php new file mode 100644 index 00000000..105e1f61 --- /dev/null +++ b/src/Attachment/PendingUploads.php @@ -0,0 +1,167 @@ +\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'rest_discard'], + 'permission_callback' => static function (): bool { + return is_user_logged_in(); + }, + 'args' => [ + 'id' => ['sanitize_callback' => 'absint'], + ], + ]); + } + + /** 上传成功后调用:标记为待认领。 */ + public static function mark(int $attachment_id): void { + if ($attachment_id > 0) { + update_post_meta($attachment_id, self::META, time()); + } + } + + public function rest_discard(\WP_REST_Request $request): \WP_REST_Response { + $id = absint($request->get_param('id')); + if (!self::discard($id, get_current_user_id())) { + return new \WP_REST_Response([ + 'success' => false, + 'message' => __('图片不存在或已被使用', 'aksrt'), + ], 404); + } + return new \WP_REST_Response(['success' => true]); + } + + /** 删除用户自己仍待认领的附件;已被引用的附件绝不删除。 */ + public static function discard(int $attachment_id, int $user_id): bool { + if (!self::is_owned_pending($attachment_id, $user_id)) { + return false; + } + return (bool) wp_delete_attachment($attachment_id, true); + } + + /** + * 按 URL 列表认领(工单附件)。 + * + * @param list $urls + */ + public static function claim_urls(array $urls, int $user_id): void { + if ($user_id <= 0) { + return; + } + foreach (array_slice(array_unique($urls), 0, self::CLAIM_MAX) as $url) { + if (!is_string($url) || $url === '') { + continue; + } + $id = (int) attachment_url_to_postid($url); + if (self::is_owned_pending($id, $user_id)) { + delete_post_meta($id, self::META); + } + } + } + + /** 从正文中提取本站上传目录下的 URL 并认领(评论 [img=…]、举报举证)。 */ + public static function claim_text(string $text, int $user_id): void { + if ($user_id <= 0 || $text === '') { + return; + } + $base = (string) (wp_get_upload_dir()['baseurl'] ?? ''); + if ($base === '' || strpos($text, wp_parse_url($base, PHP_URL_PATH) ?: $base) === false) { + return; + } + if (!preg_match_all('#https?://[^\s\[\]"\'<>]+#i', $text, $matches)) { + return; + } + $urls = array_filter($matches[0], static function (string $url) use ($base): bool { + return strpos(set_url_scheme($url), set_url_scheme($base)) === 0; + }); + self::claim_urls(array_values($urls), $user_id); + } + + /** @param int|string $approved */ + public function claim_comment($comment_id, $approved, $commentdata): void { + $data = is_array($commentdata) ? $commentdata : []; + self::claim_text((string) ($data['comment_content'] ?? ''), (int) ($data['user_id'] ?? 0)); + } + + public function claim_edited_comment($comment_id, $data = []): void { + $comment = get_comment((int) $comment_id); + if ($comment) { + self::claim_text((string) $comment->comment_content, (int) $comment->user_id); + } + } + + /** 定时清理:超过 TTL 仍未认领的附件连同文件一起删除。 */ + public function cleanup(): void { + global $wpdb; + $ids = $wpdb->get_col($wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s + WHERE p.post_type = 'attachment' AND CAST(m.meta_value AS UNSIGNED) < %d + ORDER BY p.ID ASC LIMIT %d", + self::META, + time() - self::TTL, + self::BATCH_SIZE + )); + foreach ((array) $ids as $id) { + wp_delete_attachment((int) $id, true); + } + } + + private static function is_owned_pending(int $attachment_id, int $user_id): bool { + if ($attachment_id <= 0 || $user_id <= 0) { + return false; + } + $post = get_post($attachment_id); + return $post + && $post->post_type === 'attachment' + && (int) $post->post_author === $user_id + && get_post_meta($attachment_id, self::META, true) !== ''; + } +} diff --git a/src/Ticket/TicketRest.php b/src/Ticket/TicketRest.php index 3a7ec2c3..d05f51dd 100644 --- a/src/Ticket/TicketRest.php +++ b/src/Ticket/TicketRest.php @@ -279,6 +279,7 @@ final class TicketRest { if ($id <= 0) { return new \WP_Error('aksrt_ticket_db', __('工单提交失败', 'aksrt'), ['status' => 500]); } + \AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id()); $ticket = TicketRepository::get_instance()->find($id); if (is_array($ticket)) { @@ -323,6 +324,7 @@ final class TicketRest { if ($reply_id <= 0) { return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]); } + \AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id()); $repo->touch_reply($id, 'user', get_current_user_id()); $repo->apply_reply_status($id, $ticket, 'user'); diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php index a1c03713..b99a3df1 100644 --- a/src/Ticket/TicketRestAdmin.php +++ b/src/Ticket/TicketRestAdmin.php @@ -337,6 +337,7 @@ final class TicketRestAdmin { if ($id <= 0) { return new \WP_Error('aksrt_ticket_db', __('工单创建失败', 'aksrt'), ['status' => 500]); } + \AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id()); $ticket = TicketRepository::get_instance()->find($id); if (is_array($ticket)) { TicketNotifier::user_replied($ticket); @@ -371,6 +372,7 @@ final class TicketRestAdmin { if ($reply_id <= 0) { return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]); } + \AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id()); $repo->touch_reply($id, 'admin', get_current_user_id()); $repo->apply_reply_status($id, $ticket, 'admin'); TicketNotifier::user_replied($repo->find($id) ?? $ticket); diff --git a/tests/widget-system/pending-upload-contract.test.cjs b/tests/widget-system/pending-upload-contract.test.cjs new file mode 100644 index 00000000..8a7fd3e2 --- /dev/null +++ b/tests/widget-system/pending-upload-contract.test.cjs @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8'); + +test('upload marks pending and returns attachment id', () => { + const controller = read('src/Admin/UserController.php'); + assert.match(controller, /PendingUploads::mark\(\(int\) \$attachment_id\)/); + assert.match(controller, /'id'\s*=> \(int\) \$attachment_id/); +}); + +test('pending lifecycle: discard route, owner check, claim, cron cleanup', () => { + const svc = read('src/Attachment/PendingUploads.php'); + assert.match(svc, /'\/upload\/pending\/\(\?P\\d\+\)'/); + assert.match(svc, /'methods'\s*=> 'DELETE'/); + assert.match(svc, /'permission_callback'/); + assert.match(svc, /post_author === \$user_id/); + assert.match(svc, /wp_schedule_event\(time\(\) \+ HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK\)/); + assert.match(svc, /\$wpdb->prepare\(/); + assert.match(svc, /add_action\('comment_post', \[\$this, 'claim_comment'\]/); + assert.match(read('functions.php'), /PendingUploads::get_instance\(\)->init\(\)/); +}); + +test('submit paths claim referenced uploads', () => { + const rest = read('src/Ticket/TicketRest.php'); + const admin = read('src/Ticket/TicketRestAdmin.php'); + const report = read('src/Admin/UserControllerReportOperations.php'); + assert.equal((rest.match(/PendingUploads::claim_urls\(\$attachment/g) || []).length, 2); + assert.equal((admin.match(/PendingUploads::claim_urls\(\$attachment/g) || []).length, 2); + assert.match(report, /PendingUploads::claim_text\(\$reason, \$reporter_id\)/); +}); + +test('front-end remove buttons discard the pending upload', () => { + const core = read('assets/js/ticket-center-core.js'); + const report = read('assets/js/report.js'); + assert.match(core, /'\/upload\/pending\/' \+ id/); + assert.match(core, /data-aksrt-ticket-image-id/); + assert.match(report, /'\/upload\/pending\/'/); +});