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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a308f96837
commit
ac3ed9c956
9 files changed
+247
-2
No files matched your search
+11
-1
@@ -202,7 +202,17 @@
|
|||||||
remove.className = 'remove';
|
remove.className = 'remove';
|
||||||
remove.setAttribute('aria-label', '删除图片');
|
remove.setAttribute('aria-label', '删除图片');
|
||||||
remove.innerHTML = '×';
|
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(img);
|
||||||
item.appendChild(remove);
|
item.appendChild(remove);
|
||||||
preview.insertBefore(item, preview.querySelector('.add'));
|
preview.insertBefore(item, preview.querySelector('.add'));
|
||||||
|
|||||||
@@ -246,6 +246,16 @@
|
|||||||
syncUploader(root);
|
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) {
|
function uploadOne(root, holder, file) {
|
||||||
var tile = document.createElement('div');
|
var tile = document.createElement('div');
|
||||||
tile.className = 'aksrt-ticket-image is-uploading';
|
tile.className = 'aksrt-ticket-image is-uploading';
|
||||||
@@ -264,6 +274,8 @@
|
|||||||
if (!url) return Promise.reject(result);
|
if (!url) return Promise.reject(result);
|
||||||
tile.classList.remove('is-uploading');
|
tile.classList.remove('is-uploading');
|
||||||
tile.setAttribute('data-aksrt-ticket-image', url);
|
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 = '<img src="' + escapeHtml(url) + '" alt="">'
|
tile.innerHTML = '<img src="' + escapeHtml(url) + '" alt="">'
|
||||||
+ '<button type="button" class="aksrt-ticket-image__remove" data-aksrt-ticket-image-remove aria-label="移除图片">'
|
+ '<button type="button" class="aksrt-ticket-image__remove" data-aksrt-ticket-image-remove aria-label="移除图片">'
|
||||||
+ icon('fa-times') + '</button>';
|
+ icon('fa-times') + '</button>';
|
||||||
@@ -295,7 +307,9 @@
|
|||||||
var remove = event.target.closest('[data-aksrt-ticket-image-remove]');
|
var remove = event.target.closest('[data-aksrt-ticket-image-remove]');
|
||||||
if (remove) {
|
if (remove) {
|
||||||
var root = remove.closest('form');
|
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);
|
syncUploader(root);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ if (class_exists('\AKSRT\Core\FrontSubmit')) {
|
|||||||
if (class_exists('\AKSRT\Attachment\UploadLimits')) {
|
if (class_exists('\AKSRT\Attachment\UploadLimits')) {
|
||||||
\AKSRT\Attachment\UploadLimits::get_instance()->init();
|
\AKSRT\Attachment\UploadLimits::get_instance()->init();
|
||||||
}
|
}
|
||||||
|
if (class_exists('\AKSRT\Attachment\PendingUploads')) {
|
||||||
|
\AKSRT\Attachment\PendingUploads::get_instance()->init();
|
||||||
|
}
|
||||||
if (class_exists('\AKSRT\Payment\Order')) {
|
if (class_exists('\AKSRT\Payment\Order')) {
|
||||||
\AKSRT\Payment\Order::get_instance()->init();
|
\AKSRT\Payment\Order::get_instance()->init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -612,9 +612,12 @@ class UserController extends ControllerBase {
|
|||||||
wp_delete_attachment((int) $attachment_id, true);
|
wp_delete_attachment((int) $attachment_id, true);
|
||||||
return $this->error(__('上传失败', 'aksrt'), 500);
|
return $this->error(__('上传失败', 'aksrt'), 500);
|
||||||
}
|
}
|
||||||
|
// 选图即上传:提交成功前处于待认领状态,× 移除或超时未提交会被清理。
|
||||||
|
\AKSRT\Attachment\PendingUploads::mark((int) $attachment_id);
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'message' => __('图片上传成功', 'aksrt'),
|
'message' => __('图片上传成功', 'aksrt'),
|
||||||
'data' => [
|
'data' => [
|
||||||
|
'id' => (int) $attachment_id,
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ trait UserControllerReportOperations {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($report_id > 0) {
|
if ($report_id > 0) {
|
||||||
|
// 举证图片以 URL 拼入举报内容,提交成功即认领。
|
||||||
|
\AKSRT\Attachment\PendingUploads::claim_text($reason, $reporter_id);
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'message' => __('举报已提交,我们会尽快处理', 'aksrt'),
|
'message' => __('举报已提交,我们会尽快处理', 'aksrt'),
|
||||||
'data' => ['report_id' => $report_id],
|
'data' => ['report_id' => $report_id],
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 前台「选图即上传」的附件生命周期(对齐大厂:先传后认领,未认领兜底清理)。
|
||||||
|
*
|
||||||
|
* - 上传成功即打「待认领」标记(时间戳),返回 id + url;
|
||||||
|
* - 用户点 × 移除:DELETE /upload/pending/{id},仅能删自己的、仍待认领的附件;
|
||||||
|
* - 表单提交成功(评论 / 工单 / 举报)后按引用的 URL 认领,去掉标记;
|
||||||
|
* - 定时任务删除超过 TTL 仍未认领的附件。
|
||||||
|
*
|
||||||
|
* @package AKSRT
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace AKSRT\Attachment;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
final class PendingUploads {
|
||||||
|
|
||||||
|
public const META = '_aksrt_pending_upload';
|
||||||
|
public const CRON_HOOK = 'aksrt_pending_upload_cleanup';
|
||||||
|
private const TTL = DAY_IN_SECONDS;
|
||||||
|
private const BATCH_SIZE = 100;
|
||||||
|
/** 单次认领最多解析的 URL 数,防止超长文本拖慢请求。 */
|
||||||
|
private const CLAIM_MAX = 20;
|
||||||
|
|
||||||
|
private static $instance = null;
|
||||||
|
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (null === self::$instance) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init(): void {
|
||||||
|
add_action('rest_api_init', [$this, 'register_routes']);
|
||||||
|
add_action(self::CRON_HOOK, [$this, 'cleanup']);
|
||||||
|
add_action('init', [$this, 'maybe_schedule'], 20);
|
||||||
|
// 评论走 WP 原生管线(REST 提交 / 编辑均触发),在钩子上认领,避免侵入评论控制器。
|
||||||
|
add_action('comment_post', [$this, 'claim_comment'], 10, 3);
|
||||||
|
add_action('edit_comment', [$this, 'claim_edited_comment'], 10, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybe_schedule(): void {
|
||||||
|
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||||
|
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_routes(): void {
|
||||||
|
register_rest_route('aksrt/v1', '/upload/pending/(?P<id>\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<string> $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) !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,6 +279,7 @@ final class TicketRest {
|
|||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
return new \WP_Error('aksrt_ticket_db', __('工单提交失败', 'aksrt'), ['status' => 500]);
|
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);
|
$ticket = TicketRepository::get_instance()->find($id);
|
||||||
if (is_array($ticket)) {
|
if (is_array($ticket)) {
|
||||||
@@ -323,6 +324,7 @@ final class TicketRest {
|
|||||||
if ($reply_id <= 0) {
|
if ($reply_id <= 0) {
|
||||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
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->touch_reply($id, 'user', get_current_user_id());
|
||||||
$repo->apply_reply_status($id, $ticket, 'user');
|
$repo->apply_reply_status($id, $ticket, 'user');
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ final class TicketRestAdmin {
|
|||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
return new \WP_Error('aksrt_ticket_db', __('工单创建失败', 'aksrt'), ['status' => 500]);
|
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);
|
$ticket = TicketRepository::get_instance()->find($id);
|
||||||
if (is_array($ticket)) {
|
if (is_array($ticket)) {
|
||||||
TicketNotifier::user_replied($ticket);
|
TicketNotifier::user_replied($ticket);
|
||||||
@@ -371,6 +372,7 @@ final class TicketRestAdmin {
|
|||||||
if ($reply_id <= 0) {
|
if ($reply_id <= 0) {
|
||||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
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->touch_reply($id, 'admin', get_current_user_id());
|
||||||
$repo->apply_reply_status($id, $ticket, 'admin');
|
$repo->apply_reply_status($id, $ticket, 'admin');
|
||||||
TicketNotifier::user_replied($repo->find($id) ?? $ticket);
|
TicketNotifier::user_replied($repo->find($id) ?? $ticket);
|
||||||
|
|||||||
@@ -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<id>\\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\/'/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user