From cc1c96cd868541f41bd5019c7552d5943214d997 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:12 -0700 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=AD=89=E5=BE=85=E4=B8=8E=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=81=A2=E5=A4=8D=EF=BC=8C=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E8=BF=9F=E5=88=B0=E5=93=8D=E5=BA=94=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/gallery-loading-browser.cjs | 74 ++++++++++++++++++++-- tests/gallery-loading.cjs | 75 ++++++++++++++++++++--- usr/themes/InfinityTime/assets/js/init.js | 53 +++++++++++----- 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs index 76b9bda..c631f89 100644 --- a/tests/gallery-loading-browser.cjs +++ b/tests/gallery-loading-browser.cjs @@ -54,8 +54,13 @@ const pixel = ' const page = await context.newPage(); const errors = [], requests = []; page.on('pageerror', e => errors.push(e.message)); - let broken = true, page2Fails = true, includeHistory = false, fallbackWebpFails = true; + let broken = true, page2Fails = true, includeHistory = false, fallbackWebpFails = true, holdTimeout = true; const delayed = new Map(); + async function waitForDelayed(name) { + const deadline = Date.now() + 5000; + while (!delayed.has(name) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + assert.ok(delayed.has(name), `网络故障注入请求已开始:${name}`); + } await page.route('**/*', async route => { const pathname = new URL(route.request().url()).pathname; requests.push(pathname); @@ -72,6 +77,11 @@ const pixel = ' return route.fulfill({ contentType: 'text/html', body: pageHtml(card('stale', 4, [url('stale')], ['p-8']), '') }).catch(() => {}); } if (pathname === '/page4') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('fresh', 5, [url('fresh')], ['p-9']), '') }); + if ((pathname === '/timeout-page' && holdTimeout) || pathname === '/cancel-no-abort') { + await new Promise(resolve => delayed.set(pathname, resolve)); + return route.fulfill({ contentType: 'text/html', body: pageHtml(card('late-network', 10, [url('late-network')], ['p-50']), '') }).catch(() => {}); + } + if (pathname === '/timeout-page') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('timeout-retry', 11, [url('timeout-retry')], ['p-51']), '') }); if (pathname === '/empty') return route.fulfill({ contentType: 'text/html', body: pageHtml('', '') }); if (/^\/fallback-.*\.avif$/.test(pathname)) return route.fulfill(pathname.startsWith('/fallback-decode') ? { contentType: 'image/avif', body: 'corrupt AVIF bytes' } @@ -158,9 +168,15 @@ const pixel = ' assert.equal(await page.locator('#month-2026-10').count(), 1, '追加月份标题不重复 DOM ID'); assert.equal(await page.locator('#gallery-pagination a[rel="next"]').getAttribute('href'), '/page3'); // 取消旧分页并发起新请求,旧响应迟到后不能追加内容。 - await page.evaluate(() => { window.__pendingPage = InfinityWaterfall.loadMore(); }); - while (!delayed.has('page3')) await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(await page.evaluate(() => { + window.__pendingPage = InfinityWaterfall.loadMore(); + window.__joinedPage = InfinityWaterfall.loadMore(); + return window.__pendingPage === window.__joinedPage; + }), true, '并发调用共享待完成的真实网络结果'); + await waitForDelayed('page3'); await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/page4' })); + assert.equal(await page.evaluate(() => window.__pendingPage), false, 'reset 无需等待网络响应即可结束旧请求'); + assert.equal(await page.evaluate(() => window.__joinedPage), false); delayed.get('page3')(); assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true); assert.equal(await page.locator('#stale').count(), 0); @@ -174,17 +190,65 @@ const pixel = ' assert.equal(await page.locator('.pp-load-retry').isVisible(), false); // 自动滚动另行通过真实事件路径验证,不依赖上面的手动调用时序。 const emptyRequests = requests.filter(p => p === '/empty').length; - await page.evaluate(() => { + assert.equal(await page.evaluate(() => { InfinityWaterfall.reset({ nextUrl: '/empty' }); window.scrollTo(0, document.documentElement.scrollHeight); window.dispatchEvent(new Event('scroll')); - }); + return InfinityWaterfall.loadMore(); + }), true, '主动调用等待已在进行的自动滚动分页,而非立即返回 false'); await page.waitForFunction(() => { const more = document.querySelector('#load-more'); return more.getAttribute('data-next-url') === '' && more.getAttribute('aria-busy') === 'false'; }); assert.equal(requests.filter(p => p === '/empty').length, emptyRequests + 1, '自动滚动恰好加载一次合法末尾空批次'); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); + // 无 AbortController 的实际浏览器请求:超时与取消必须结束等待, + // 即使网络仍在传输,迟到响应也不能写入网格或干扰重试。 + await page.evaluate(() => { + window.__savedAbortController = window.AbortController; + window.AbortController = undefined; + const schedule = window.setTimeout; + window.setTimeout = function(callback, delay, ...args) { + if (delay === 20000) window.__expireWaterfall = callback; + return schedule(callback, delay, ...args); + }; + try { + InfinityWaterfall.reset({ nextUrl: '/timeout-page' }); + window.__timedOutPage = InfinityWaterfall.loadMore(); + } finally { window.setTimeout = schedule; } + }); + await waitForDelayed('/timeout-page'); + await page.evaluate(() => window.__expireWaterfall()); + assert.equal(await page.evaluate(() => window.__timedOutPage), false, '缺少 AbortController 时超时仍可结束'); + assert.equal(await page.locator('#load-more').getAttribute('aria-busy'), 'false'); + assert.match(await page.locator('.pp-load-status').innerText(), /加载失败/); + holdTimeout = false; + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '超时后能成功重试同一页'); + assert.equal(await page.locator('#timeout-retry').count(), 1); + const lateTimeoutResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/timeout-page'); + delayed.get('/timeout-page')(); + await (await lateTimeoutResponse).finished(); + assert.equal(await page.locator('#late-network').count(), 0, '超时后的迟到页面被隔离'); + await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/cancel-no-abort' }); + window.__cancelledPage = InfinityWaterfall.loadMore(); + }); + await waitForDelayed('/cancel-no-abort'); + const cancelledState = await page.evaluate(() => { + InfinityWaterfall.cancel(); + const more = document.querySelector('#load-more'); + const state = { busy: more.getAttribute('aria-busy'), failed: more.classList.contains('pp-load-error') }; + // 同一任务内清空游标,避免恢复可重试状态后原生滚动再次请求该测试页。 + InfinityWaterfall.reset({ nextUrl: '' }); + window.AbortController = window.__savedAbortController; + return state; + }); + assert.equal(await page.evaluate(() => window.__cancelledPage), false, '缺少 AbortController 时取消也立即结束'); + assert.deepEqual(cancelledState, { busy: 'false', failed: false }); + const lateCancelledResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/cancel-no-abort'); + delayed.get('/cancel-no-abort')(); + await (await lateCancelledResponse).finished(); + assert.equal(await page.locator('#late-network').count(), 0, '取消后的迟到页面被隔离'); // AVIF 请求和解码失败均必须尝试可用 WebP,不能仅显示永久失败。 await page.evaluate(html => { document.querySelector('#waterfall').insertAdjacentHTML('beforeend', html); diff --git a/tests/gallery-loading.cjs b/tests/gallery-loading.cjs index 2e43d52..4e88fce 100644 --- a/tests/gallery-loading.cjs +++ b/tests/gallery-loading.cjs @@ -205,21 +205,26 @@ class Element { assert.deepEqual(Array.from(result.colors), []); } // 通过可控网络响应验证真实分页加载器的失败、去重、取消、失效响应、 - // 月份标题和末尾空批次处理。 - { + // 月份标题和末尾空批次处理;无 AbortController 时也必须能超时和取消。 + for (const abortSupported of [true, false]) { const source = read('init'); const wf = new Element(), lm = new Element(), nav = new Element(); const next = nav.appendChild(new Element('a')); next.setAttribute('rel', 'next'); lm.setAttribute('data-next-url', '/page2'); const nodes = { waterfall: wf, 'load-more': lm, 'gallery-pagination': nav, 'month-2026-10': new Element('h2') }; - const pending = [], events = {}, docs = {}; + const pending = [], events = {}, docs = {}, timers = new Map(); + let timerId = 0, throwFetch = false; let rebinds = 0; const context = { document: { getElementById: id => nodes[id] || null, createElement: tag => new Element(tag), documentElement: { offsetHeight: 3000 }, dispatchEvent() {} }, window: { innerHeight: 800, scrollY: 0, addEventListener: (name, fn) => events[name] = fn, __rebindPoptrox: () => rebinds++ }, - location: { href: 'https://gallery.test/' }, URL, Promise, AbortController, - setTimeout, clearTimeout, applyDims() {}, initImageFallback() {}, - fetch: (url, options) => new Promise(resolve => pending.push({ url, options, resolve })), + location: { href: 'https://gallery.test/' }, URL, Promise, AbortController: abortSupported ? AbortController : undefined, + setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id), + applyDims() {}, initImageFallback() {}, + fetch: (url, options) => { + if (throwFetch) throw new Error('synchronous fetch failure'); + return new Promise((resolve, reject) => pending.push({ url, options, resolve, reject })); + }, DOMParser: class { parseFromString(body) { return docs[body]; } }, CustomEvent: class { constructor(name, options) { this.type = name; this.detail = options.detail; } } }; @@ -232,11 +237,33 @@ class Element { } vm.createContext(context); vm.runInContext(helper(source, 'initWaterfall', 2), context); context.initWaterfall(); const api = context.window.InfinityWaterfall; + const tick = () => new Promise(resolve => setImmediate(resolve)); + async function settled(promise, expected, message) { + assert.equal(await Promise.race([promise, tick().then(() => 'still pending')]), expected, message); + } + const timedOut = api.loadMore(), timedOutRequest = pending.shift(); + assert.equal(timers.size, 1); + [...timers.values()][0](); + await settled(timedOut, false, '超时必须结束请求 Promise,即使 fetch 不支持或忽略 abort'); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.match(lm.children[0].textContent, /加载失败/); + assert.equal(timers.size, 0); + if (abortSupported) assert.equal(timedOutRequest.options.signal.aborted, true); + const timeoutRetry = api.loadMore(), timeoutRetryRequest = pending.shift(); + timedOutRequest.resolve(response('late-timeout', '/wrong', 3)); + await tick(); + assert.equal(wf.children.length, 0, '超时后旧响应不能追加卡片'); + assert.equal(lm.getAttribute('aria-busy'), 'true', '旧请求完成不能释放新请求的 busy 状态'); + timeoutRetryRequest.resolve({ ok: false, status: 503 }); + await settled(timeoutRetry, false, '超时后可立即重试'); const failed = api.loadMore(); - assert.equal(await api.loadMore(), false); assert.equal(pending.length, 1, '并发请求不会重复加载同一页'); + const joined = api.loadMore(); + assert.equal(joined, failed, '并发调用应等待同一分页结果,不能将 busy 误报为失败'); + assert.equal(pending.length, 1, '并发请求不会重复加载同一页'); assert.equal(lm.getAttribute('aria-busy'), 'true'); pending.shift().resolve({ ok: false, status: 503 }); assert.equal(await failed, false); + assert.equal(await joined, false); assert.equal(lm.getAttribute('aria-busy'), 'false'); assert.match(lm.children[0].textContent, /加载失败/); context.window.scrollY = 4000; events.scroll(); assert.equal(pending.length, 0, '自动加载失败后等待主动重试'); @@ -245,7 +272,11 @@ class Element { assert.equal(wf.children[0].getAttribute('id'), null, '追加标题的重复 ID 已移除'); assert.equal(next.getAttribute('href'), '/page3'); const stale = api.loadMore(); const staleRequest = pending.shift(); - api.reset({ nextUrl: '/page4' }); assert.equal(staleRequest.options.signal.aborted, true); + const staleJoined = api.loadMore(); + api.reset({ nextUrl: '/page4' }); + if (abortSupported) assert.equal(staleRequest.options.signal.aborted, true); + await settled(stale, false, 'reset 在底层网络不响应时仍结束旧请求'); + await settled(staleJoined, false, 'reset 同时结束所有并发等待者'); staleRequest.resolve(response('stale', '/page5', 3)); assert.equal(await stale, false); assert.equal(wf.children.length, 2, '重置后旧响应不得追加卡片'); const fresh = api.loadMore(); pending.shift().resolve(response('fresh', '', 1)); @@ -259,6 +290,34 @@ class Element { const invalid = api.loadMore(); pending.shift().resolve({ ok: true, text: () => Promise.resolve('invalid') }); assert.equal(await invalid, false); assert.match(lm.children[0].textContent, /加载失败/); api.cancel(); + api.reset({ nextUrl: '/body-stall' }); + const bodyStall = api.loadMore(); + let finishBody; + pending.shift().resolve({ ok: true, text: () => new Promise(resolve => { finishBody = resolve; }) }); + await tick(); + [...timers.values()][0](); + await settled(bodyStall, false, '响应头已返回但响应体挂起也必须超时'); + finishBody('fresh'); await tick(); + assert.equal(wf.children.length, 5, '超时响应体不会追加旧内容'); + api.reset({ nextUrl: '/cancelled' }); + const cancelled = api.loadMore(), cancelledRequest = pending.shift(); + events.pagehide(); + await settled(cancelled, false, 'pagehide 结束挂起请求'); + assert.equal(timers.size, 0); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.equal(lm.classes.has('pp-load-error'), false, '取消不冒充网络失败'); + cancelledRequest.reject(new Error('late cancellation failure')); + await tick(); + assert.equal(lm.classes.has('pp-load-error'), false); + throwFetch = true; + await settled(api.loadMore(), false, '同步 fetch 异常也释放请求状态'); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.equal(timers.size, 0); + throwFetch = false; + const recovered = api.loadMore(); pending.shift().resolve(response('recovered', '', 1)); + assert.equal(await recovered, true); + assert.equal(lm.classes.has('pp-load-error'), false); + assert.equal(timers.size, 0); } console.log('图册预加载、主题色和分页生命周期回归测试通过'); })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/usr/themes/InfinityTime/assets/js/init.js b/usr/themes/InfinityTime/assets/js/init.js index 6a371c1..c3d8104 100644 --- a/usr/themes/InfinityTime/assets/js/init.js +++ b/usr/themes/InfinityTime/assets/js/init.js @@ -114,7 +114,7 @@ var totPages = parseInt(lm.getAttribute('data-total-pages'), 10) || 1; var cursorMode = lm.hasAttribute('data-next-url'); var nextUrl = cursorMode ? lm.getAttribute('data-next-url') : ''; - var loadingMore = false, failed = false, requestId = 0, controller = null, timeout = null; + var loadingMore = false, failed = false, requestId = 0, activeRequest = null; var status = document.createElement('span'); status.className = 'pp-load-status'; status.setAttribute('role', 'status'); @@ -135,28 +135,53 @@ } function cancel() { requestId++; - if (controller) controller.abort(); - controller = null; - clearTimeout(timeout); + var request = activeRequest; + activeRequest = null; + if (request) { + request.finish(false, false); + if (request.controller) request.controller.abort(); + } loadingMore = false; update(); } function loadMore(manual) { - if (loadingMore || !hasMore() || (failed && !manual)) return Promise.resolve(false); + // 自动滚动与主动请求共享结果;busy 不能被调用方当作分页失败。 + if (activeRequest) return activeRequest.promise; + if (!hasMore() || (failed && !manual)) return Promise.resolve(false); var id = ++requestId; var requestedUrl = cursorMode ? nextUrl : pagerBase + (curPage + 1); loadingMore = true; failed = false; update(); - controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + var request = { controller: typeof AbortController !== 'undefined' ? new AbortController() : null, done: false }; + request.promise = new Promise(function(resolve) { + request.finish = function(result, error) { + if (request.done) return; + request.done = true; + clearTimeout(request.timeout); + if (id === requestId) { + activeRequest = null; loadingMore = false; failed = !!error; update(); + } + resolve(result); + }; + }); + activeRequest = request; var options = { credentials: 'same-origin' }; - if (controller) options.signal = controller.signal; - timeout = setTimeout(function() { if (id === requestId && controller) controller.abort(); }, 20000); - return fetch(requestedUrl, options) + if (request.controller) options.signal = request.controller.signal; + request.timeout = setTimeout(function() { + // abort 只是尽力停止网络;即使不可用或底层忽略,也要结束等待并隔离迟到响应。 + request.finish(false, true); + if (request.controller) request.controller.abort(); + }, 20000); + var network; + try { network = fetch(requestedUrl, options); } + catch (error) { request.finish(false, true); return request.promise; } + Promise.resolve(network) .then(function(response) { + if (request.done || id !== requestId) return null; if (!response.ok) throw new Error('HTTP ' + response.status); return response.text(); }) .then(function(html) { - if (id !== requestId || !wf.isConnected) return false; + if (request.done || id !== requestId || !wf.isConnected) return false; var doc = new DOMParser().parseFromString(html, 'text/html'); var source = doc.getElementById('waterfall'); var cards = source ? Array.from(source.querySelectorAll(':scope > .thumb')) : []; @@ -184,11 +209,9 @@ document.dispatchEvent(new CustomEvent('infinitygallery:append', { detail: { count: cards.length, page: curPage, nextUrl: nextUrl } })); return true; }) - .catch(function() { if (id === requestId) failed = true; return false; }) - .then(function(result) { - if (id === requestId) { clearTimeout(timeout); controller = null; loadingMore = false; update(); } - return result; - }); + .then(function(result) { request.finish(result, false); }) + .catch(function() { request.finish(false, true); }); + return request.promise; } button.addEventListener('click', function() { loadMore(true); }); window.addEventListener('scroll', function() { From bcd4f70bb89ac3354b49f2290af07a118dfea6b3 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:31:56 -0700 Subject: [PATCH 2/5] =?UTF-8?q?=E5=8E=9F=E5=AD=90=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E4=B8=8E=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=EF=BC=8C=E4=BF=9D=E6=8A=A4=E5=A4=96=E5=B1=82?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E5=8F=8A=E5=A4=B1=E8=B4=A5=E5=89=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + tests/admin-workflow.php | 1 + tests/edit-workflow.php | 209 ++++++++++++++++++ tests/typecho-integration.php | 3 + .../InfinityTime/Lib/AdminRepository.php | 19 +- .../InfinityTime/Lib/AdminWorkflow.php | 35 ++- usr/plugins/InfinityTime/Lib/Database.php | 92 ++++++++ .../InfinityTime/Lib/ImageRepository.php | 128 ++++------- usr/plugins/InfinityTime/panel.php | 68 +++--- usr/themes/InfinityTime/update.md | 7 + 10 files changed, 430 insertions(+), 136 deletions(-) create mode 100644 tests/edit-workflow.php diff --git a/README.md b/README.md index 7b71bf2..a0e48fe 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ ## 功能特性 +### 开发分支:异常恢复加固(未发布) + +分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本轮保留版本1.14.1,不自动合并、发布或部署。 + ### v1.14.1 性能与可靠性加固 维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。 diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php index 614f285..533b39e 100644 --- a/tests/admin-workflow.php +++ b/tests/admin-workflow.php @@ -51,6 +51,7 @@ public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, n public static function get(): self { return self::$instance; } public function getPrefix(): string { return 'admin_'; } public function getAdapterName(): string { return $this->adapter; } + public function selectDb($op) { return $this->pdo; } public function select(...$fields): Query { return new Query('select', '', $fields); } public function insert($table): Query { return new Query('insert', $table); } public function update($table): Query { return new Query('update', $table); } diff --git a/tests/edit-workflow.php b/tests/edit-workflow.php new file mode 100644 index 0000000..25c619f --- /dev/null +++ b/tests/edit-workflow.php @@ -0,0 +1,209 @@ + '编辑原子性', 'device' => '旧设备', 'tags' => '旧标签', 'address' => '旧地点'], 'draft-edit-workflow'); + $cid = (int)$draft['cid']; + $content = static function (int $id) use ($db): array { + return AdminRepository::readRow($db->select()->from('table.contents')->where('cid = ?', $id), true); + }; + $snapshot = fields($cid); + // 复现旧 setField 的 DELETE/INSERT 分散写:插入失败后旧值确实丢失。 + // 仅操作一次性 fixture,不调用或修改生产实现。 + failureTrigger(true); + try { + injectedFailure(static function () use ($db, $cid): void { + Database::query($db->delete('table.fields')->where('cid = ?', $cid)->where('name = ?', 'tags')); + Database::query($db->insert('table.fields')->rows(['cid' => $cid, 'name' => 'tags', 'type' => 'str', 'str_value' => '失败新值'])); + }, '旧分散字段写入的插入故障能够复现'); + } finally { failureTrigger(false); } + check(AdminRepository::field($cid, 'tags') === '', '旧 DELETE/INSERT 流程故障导致旧标签丢失'); + AdminRepository::setField($cid, 'tags', '旧标签'); + check(fields($cid) === $snapshot, '复现后恢复 fixture 基线'); + + failureTrigger(true); + try { + injectedFailure(static function () use ($cid): void { AdminRepository::setField($cid, 'tags', '新标签'); }, '原子单字段插入故障'); + check(fields($cid) === $snapshot, '原子单字段失败保留全部旧字段'); + injectedFailure(static function () use ($cid): void { + AdminRepository::setFields($cid, ['device' => '新设备', 'tags' => '新标签', 'location' => '新地点']); + }, '多字段第二次插入故障'); + check(fields($cid) === $snapshot, '多字段中途失败恢复此前字段与待替换字段'); + $oldContent = $content($cid); + injectedFailure(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '新标题', 'device' => '新设备', 'tags' => '新标签', 'address' => '新地点']); + }, '图集标题更新后字段故障'); + check($content($cid) === $oldContent && fields($cid) === $snapshot, '标题、修改时间及三字段必须共同回滚'); + } finally { failureTrigger(false); } + rejected(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 8, false, true, ['title' => '越权标题']); + }, \DomainException::class, '图集编辑拒绝其他作者'); + check($content($cid) === $oldContent && fields($cid) === $snapshot, '越权图集编辑不改动记录'); + + // BEGIN 及调用方 SAVEPOINT:成功与失败均不可提交调用方事务。 + foreach (['BEGIN', 'SAVEPOINT caller_edit'] as $outer) { + Database::query('BEGIN'); + if ($outer !== 'BEGIN') { Database::query($outer); } + try { + AdminRepository::setField($cid, 'device', '外层设备'); + $nested = AdminWorkflow::draft(7, ['title' => '外层草稿', 'tags' => 'outer'], 'draft-nested-edit-' . ($outer === 'BEGIN' ? 'begin' : 'savepoint')); + check($content((int)$nested['cid']) !== [], '外层事务内草稿可见'); + check($db->fetchRow($db->select()->from('table.contents')->where('cid = ?', (int)$nested['cid'])) === null, '嵌套草稿不得提交到读连接'); + check(AdminRepository::draftForOperation(7, 'draft-nested-edit-' . ($outer === 'BEGIN' ? 'begin' : 'savepoint')) !== null, '嵌套草稿操作标识从主库可读'); + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '未提交标题', 'device' => '未提交设备', 'tags' => '未提交标签', 'address' => '未提交地点']); + check($content($cid)['title'] === '未提交标题', '嵌套图集编辑在主库可见'); + check(AdminRepository::album($cid, 7, false)['title'] === $oldContent['title'], '嵌套图集编辑不提前提交'); + if ($outer !== 'BEGIN') { + Database::query('ROLLBACK TO SAVEPOINT caller_edit'); + check($content((int)$nested['cid']) === [] && $content($cid) === $oldContent, '调用方保存点回滚撤销草稿和编辑'); + Database::query('RELEASE SAVEPOINT caller_edit'); + } + } finally { Database::query('ROLLBACK'); } + check($content((int)$nested['cid']) === [] && fields($cid) === $snapshot && $content($cid) === $oldContent, '外层回滚撤销全部嵌套草稿和编辑'); + } + if ($dialect === 'SQLite') { + // SQLite 可以由 SAVEPOINT 自身开启事务;内层不能 RELEASE 掉调用方边界。 + Database::query('SAVEPOINT caller_standalone'); + try { + $standalone = AdminWorkflow::draft(7, ['title' => '独立保存点草稿'], 'draft-standalone-savepoint'); + check($content((int)$standalone['cid']) !== [], '独立保存点内创建草稿可见'); + check($db->fetchRow($db->select()->from('table.contents')->where('cid = ?', (int)$standalone['cid'])) === null, '独立保存点草稿不得提前提交'); + Database::query('ROLLBACK TO SAVEPOINT caller_standalone'); + check($content((int)$standalone['cid']) === [], '调用方独立保存点仍能回滚草稿'); + } finally { Database::query('RELEASE SAVEPOINT caller_standalone'); } + } + failureTrigger(true); + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '外层故障前写入'); + injectedFailure(static function (): void { AdminWorkflow::draft(7, ['title' => '嵌套失败草稿', 'tags' => 'fail'], 'draft-nested-edit-failure'); }, '嵌套草稿字段故障'); + check(AdminRepository::field($cid, 'device', true) === '外层故障前写入', '失败草稿不得回滚调用方既有写入'); + check(AdminRepository::draftForOperation(7, 'draft-nested-edit-failure') === null, '嵌套失败草稿无操作标识残留'); + injectedFailure(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '失败标题', 'device' => '失败设备', 'tags' => 'fail']); + }, '嵌套图集编辑字段故障'); + check($content($cid) === $oldContent && AdminRepository::field($cid, 'device', true) === '外层故障前写入', '嵌套编辑失败恢复自身修改而保留调用方写入'); + } finally { Database::query('ROLLBACK'); failureTrigger(false); } + check(fields($cid) === $snapshot, '失败嵌套工作流后外层仍可回滚'); + + $one = photo($cid, ['title' => '第一张', 'full' => '/fixture/one.webp'], 10); + $two = photo($cid, ['title' => '第二张', 'full' => '/fixture/two.webp'], 20); + $three = photo($cid, ['title' => '第三张', 'full' => '/fixture/three.webp'], 30); + $other = AdminWorkflow::draft(8, ['title' => '排序隔离'], 'draft-edit-other-album'); + $otherId = photo((int)$other['cid']); + ImageRepository::syncPostFields($cid); + $images = ImageRepository::rowsFor($cid); + $snapshot = fields($cid); + check(!ImageRepository::editImage($one, (int)$other['cid'], '错误图集', '错描述', '错地点'), '编辑图片必须匹配图集 ID'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '错误图集不能改变图片与公共字段'); + failureTrigger(true, 'titles'); + try { + injectedFailure(static function () use ($one, $cid): void { ImageRepository::editImage($one, $cid, '新图片标题', '新描述', '新地点'); }, '图片更新后公共字段插入失败'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '图片更新及公共字段全部回滚'); + injectedFailure(static function () use ($one, $two, $three, $cid): void { ImageRepository::sortImages($cid, [$three, $one, $two]); }, '排序后公共字段插入失败'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '公共字段故障恢复全部图片顺序'); + } finally { failureTrigger(false); } + foreach ([[$one, $two], [$one, $two, $two], [$one, $two, $otherId], [$one, $two, $three, $otherId], [], [$one, $two, (string)$three], [$one, $two, 0]] as $invalid) { + rejected(static function () use ($cid, $invalid): void { ImageRepository::sortImages($cid, $invalid); }, \DomainException::class, '排序拒绝不完整、重复、跨图集或非法 ID 清单'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '非法排序不改动任何行及公共字段'); + } + + // 在反向排序的第二个 UPDATE 注入失败,确保此前第一行 UPDATE 也撤销。 + $updateTrigger = static function (bool $enabled) use ($prefix, $dialect, $two): void { + $name = $prefix . 'reject_edit_update'; + $fn = $prefix . 'reject_edit_update_fn'; + if (!$enabled) { + Database::query('DROP TRIGGER IF EXISTS ' . $name . ($dialect === 'Pgsql' ? ' ON ' . table('infinitytime_images') : '')); + if ($dialect === 'Pgsql') { Database::query('DROP FUNCTION IF EXISTS ' . $fn . '()'); } + return; + } + if ($dialect === 'SQLite') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . " WHEN NEW.id = $two BEGIN SELECT RAISE(ABORT, 'Integration failure'); END"; + } elseif ($dialect === 'Mysql') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . " FOR EACH ROW BEGIN IF NEW.id = $two THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration failure'; END IF; END"; + } else { + Database::query('CREATE FUNCTION ' . $fn . "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.id = $two THEN RAISE EXCEPTION 'Integration failure'; END IF; RETURN NEW; END $$"); + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . ' FOR EACH ROW EXECUTE PROCEDURE ' . $fn . '()'; + } + Database::query($sql); + }; + $updateTrigger(true); + try { + injectedFailure(static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }, '排序第二行 UPDATE 故障'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '排序 UPDATE 中途失败撤销此前顺序写入'); + } finally { $updateTrigger(false); } + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '图片编辑外层设备'); + check(ImageRepository::editImage($one, $cid, '嵌套图片标题', '嵌套描述', '嵌套地点'), '嵌套图片编辑成功'); + ImageRepository::sortImages($cid, [$three, $one, $two]); + check(json_decode(AdminRepository::field($cid, 'photo_ids', true), true) === [$three, $one, $two], '嵌套排序与公共 ID 对齐'); + check(json_decode(AdminRepository::field($cid, 'titles', true), true)[1] === '嵌套图片标题', '嵌套排序保留已编辑标题'); + check(AdminRepository::field($cid, 'device') === '旧设备', '图片编辑与排序不得提交外层修改'); + } finally { Database::query('ROLLBACK'); } + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '外层回滚恢复图片编辑、排序及公共字段'); + $writerImages = static function (int $albumId) use ($db): array { + return AdminRepository::readAll($db->select()->from(ImageRepository::table())->where('cid = ?', $albumId) + ->order('sort', Db::SORT_ASC)->order('id', Db::SORT_ASC), true); + }; + $writerFields = static function (int $albumId) use ($db): array { + return AdminRepository::readAll($db->select()->from('table.fields')->where('cid = ?', $albumId)->order('name', Db::SORT_ASC), true); + }; + $imagesOnWriter = $writerImages($cid); + failureTrigger(true, 'titles'); + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '图片失败前设备'); + $fieldsOnWriter = $writerFields($cid); + injectedFailure(static function () use ($one, $cid): void { ImageRepository::editImage($one, $cid, '失败图片', '失败描述', '失败地点'); }, '嵌套图片编辑故障'); + check($writerImages($cid) === $imagesOnWriter && $writerFields($cid) === $fieldsOnWriter, '图片编辑失败后主库已恢复自身写入,不依赖外层回滚遮盖'); + injectedFailure(static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }, '嵌套图片排序故障'); + check($writerImages($cid) === $imagesOnWriter && $writerFields($cid) === $fieldsOnWriter, '排序失败后主库已恢复自身写入,保留调用方字段'); + check(ImageRepository::rowsFor($cid) === $images && AdminRepository::field($cid, 'device', true) === '图片失败前设备', '嵌套图片操作失败保留调用方事务'); + } finally { Database::query('ROLLBACK'); failureTrigger(false); } + check(fields($cid) === $snapshot, '图片失败后外层事务仍可完整回滚'); + + if ($dialect === 'Mysql') { + foreach (['fields', 'contents', 'infinitytime_images'] as $nonTransactional) { + Database::query('ALTER TABLE ' . table($nonTransactional) . ' ENGINE=MyISAM'); + try { + $operations = []; + if ($nonTransactional !== 'infinitytime_images') { + $operations[] = static function () use ($cid): void { AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '不应写入', 'tags' => '不应写入']); }; + $operations[] = static function (): void { AdminWorkflow::draft(7, ['title' => '不应创建', 'tags' => '不应写入'], 'draft-edit-myisam-fail'); }; + } + if ($nonTransactional === 'fields') { + $operations[] = static function () use ($cid): void { AdminRepository::setField($cid, 'tags', '不应写入'); }; + $operations[] = static function () use ($cid): void { AdminRepository::setFields($cid, ['device' => '不应写入', 'tags' => '不应写入']); }; + } + if ($nonTransactional !== 'contents') { + $operations[] = static function () use ($cid, $one): void { ImageRepository::editImage($one, $cid, '不应写入', '', ''); }; + $operations[] = static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }; + } + foreach ($operations as $operation) { + $beforeCounts = [rowCount('contents'), rowCount('fields'), rowCount('infinitytime_images')]; + rejected($operation, \RuntimeException::class, $nonTransactional . ' 为 MyISAM 时写前拒绝'); + check($content($cid) === $oldContent && fields($cid) === $snapshot && ImageRepository::rowsFor($cid) === $images, 'MyISAM 拒绝后现有图集三表完全不变'); + check(AdminRepository::draftForOperation(7, 'draft-edit-myisam-fail') === null, 'MyISAM 拒绝后不产生草稿'); + check([rowCount('contents'), rowCount('fields'), rowCount('infinitytime_images')] === $beforeCounts, 'MyISAM 拒绝后无孤立文章或字段残留'); + } + } finally { Database::query('ALTER TABLE ' . table($nonTransactional) . ' ENGINE=InnoDB'); } + } + } + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '成功标题', 'device' => '成功设备', 'tags' => '成功标签', 'address' => '成功地点']); + check($content($cid)['title'] === '成功标题' && AdminRepository::field($cid, 'device') === '成功设备' + && AdminRepository::field($cid, 'tags') === '成功标签' && AdminRepository::field($cid, 'location') === '成功地点', '正常图集编辑共同提交标题及三字段'); + check(ImageRepository::editImage($one, $cid, "照片 O'Reilly ? 雨", '成功描述', '成功地点'), '正常图片编辑返回成功'); + ImageRepository::sortImages($cid, [$three, $one, $two]); + $edited = ImageRepository::rowsFor($cid)[1]; + check($edited['title'] === "照片 O'Reilly ? 雨" && $edited['desc'] === '成功描述' && $edited['address'] === '成功地点', '编辑成功保存图片行三项元数据'); + check(json_decode(AdminRepository::field($cid, 'descs'), true)[1] === '成功描述' && json_decode(AdminRepository::field($cid, 'addresses'), true)[1] === '成功地点', '编辑描述和地点也与公共字段对齐'); + check(array_map(static function (array $row): int { return (int)$row['id']; }, ImageRepository::rowsFor($cid)) === [$three, $one, $two], '完整清单正常提交精确顺序'); + check(json_decode(AdminRepository::field($cid, 'photo_ids'), true) === [$three, $one, $two] + && json_decode(AdminRepository::field($cid, 'titles'), true)[1] === "照片 O'Reilly ? 雨", '提交后的公共字段与新图片及顺序对齐'); +})(); diff --git a/tests/typecho-integration.php b/tests/typecho-integration.php index caa4d6f..ee7d9d8 100644 --- a/tests/typecho-integration.php +++ b/tests/typecho-integration.php @@ -409,6 +409,9 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void foreach (glob($tmp . '/replica.sqlite*') ?: [] as $file) { @unlink($file); } } + // 原子编辑、嵌套事务、故障注入与非事务引擎回归。 + require __DIR__ . '/edit-workflow.php'; + // 真实删除事务:数据库字段失败、外层回滚都不能提前移除实体文件。 $deleteDraft = AdminWorkflow::draft(7, ['title' => '删除事务验证', 'device' => '删除前设备'], 'draft-delete-tests'); $deleteCid = (int)$deleteDraft['cid']; diff --git a/usr/plugins/InfinityTime/Lib/AdminRepository.php b/usr/plugins/InfinityTime/Lib/AdminRepository.php index 2c5339e..0d07c9f 100644 --- a/usr/plugins/InfinityTime/Lib/AdminRepository.php +++ b/usr/plugins/InfinityTime/Lib/AdminRepository.php @@ -135,13 +135,24 @@ public static function field(int $cid, string $name, bool $writer = false): stri } public static function setField(int $cid, string $name, string $value): void + { + self::setFields($cid, [$name => $value]); + } + + /** 多字段替换共享一个保存点,单字段也不会因插入失败丢失旧值。 */ + public static function setFields(int $cid, array $values): void { $db = Db::get(); $table = $db->getPrefix() . 'fields'; - Database::query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name)); - if ($value !== '') { - Database::query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value])); - } + Database::transaction(static function () use ($db, $table, $cid, $values): void { + foreach ($values as $name => $value) { + if (!is_string($name) || !is_string($value)) { throw new \InvalidArgumentException('字段名称与值必须是字符串'); } + Database::query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name)); + if ($value !== '') { + Database::query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value])); + } + } + }, [$table]); } public static function draftForOperation(int $uid, string $key): ?array diff --git a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php index 8a346da..1e02ba4 100644 --- a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php +++ b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php @@ -17,8 +17,8 @@ public static function draft(int $uid, array $data, string $key): array if ($title === '') { throw new \InvalidArgumentException('请填写图集标题'); } $db = Db::get(); $now = time(); - Database::query('BEGIN'); - try { + $cid = 0; + Database::transaction(static function () use ($db, $title, $now, $uid, $key, $data, &$cid): void { $cid = (int)Database::query($db->insert($db->getPrefix() . 'contents')->rows([ 'title' => $title, 'slug' => 'album-' . date('YmdHis', $now) . '-' . bin2hex(random_bytes(6)), 'created' => $now, 'modified' => $now, 'text' => '', 'authorId' => $uid, @@ -26,16 +26,12 @@ public static function draft(int $uid, array $data, string $key): array 'allowFeed' => '0', 'template' => '', 'password' => '', ])); if ($cid <= 0) { throw new \RuntimeException('图集创建失败'); } - AdminRepository::setField($cid, AdminRepository::MARKER, '1'); - AdminRepository::setField($cid, AdminRepository::OPERATION, $key); + $fields = [AdminRepository::MARKER => '1', AdminRepository::OPERATION => $key]; foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { - AdminRepository::setField($cid, $name, trim((string)($data[$input] ?? ''))); + $fields[$name] = trim((string)($data[$input] ?? '')); } - Database::query('COMMIT'); - } catch (\Throwable $e) { - Database::query('ROLLBACK'); - throw $e; - } + AdminRepository::setFields($cid, $fields); + }, [$db->getPrefix() . 'contents', $db->getPrefix() . 'fields']); return ['cid' => $cid, 'title' => $title, 'status' => 'draft', 'replayed' => false]; } @@ -47,6 +43,25 @@ public static function requireAlbum(int $cid, int $uid, bool $admin, bool $canPu return $album; } + /** 图集标题及全部手动字段同事务更新;权限在持锁后重新检查。 */ + public static function updateAlbum(int $cid, int $uid, bool $admin, bool $canPublish, array $data): void + { + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + $db = Db::get(); + $title = trim((string)($data['title'] ?? '')); + $fields = []; + foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { + $fields[$name] = trim((string)($data[$input] ?? '')); + } + Database::transaction(static function () use ($db, $cid, $title, $fields): void { + $values = ['modified' => time()]; + if ($title !== '') { $values['title'] = $title; } + Database::query($db->update($db->getPrefix() . 'contents')->rows($values)->where('cid = ?', $cid)); + AdminRepository::setFields($cid, $fields); + }, [$db->getPrefix() . 'contents', $db->getPrefix() . 'fields']); + } + /** 上传标识绑定文件内容与逐图元数据,不受转换设置变化影响。 */ public static function fingerprint(array $file, array $data): string { diff --git a/usr/plugins/InfinityTime/Lib/Database.php b/usr/plugins/InfinityTime/Lib/Database.php index d52becc..823cccf 100644 --- a/usr/plugins/InfinityTime/Lib/Database.php +++ b/usr/plugins/InfinityTime/Lib/Database.php @@ -31,4 +31,96 @@ public static function query($query) if ($action === 'UPDATE' || $action === 'DELETE') { return $handle->affected_rows; } return $result; } + + /** + * 在主库保存点中执行受控写入,保留调用方事务边界。 + * $tables 仅由内部调用点给出真实表名,用于写入前验证 MySQL 事务引擎。 + * 返回 true 仅表示本方法创建且成功提交了事务;文件清理须以此为依据。 + */ + public static function transaction(callable $operation, array $tables): bool + { + $db = Db::get(); + $adapter = strtolower($db->getAdapterName()); + $handle = $db->selectDb(Db::WRITE); + $write = static function ($query) { + // 所有同步语句编译后固定走主库;不需要 INSERT 的生成主键。 + return self::query(is_string($query) ? $query : $query->prepare((string)$query)); + }; + $savepoint = 'infinitytime_fields_' . bin2hex(random_bytes(8)); + $owned = false; + $mysql = strpos($adapter, 'mysql') !== false; + if ($mysql) { + // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。 + foreach ($tables as $name) { + $query = $db->select(); + $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($name)); + $table = $db->fetchRow($write($sql)); + if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) { + throw new \RuntimeException('此操作需要相关表使用 InnoDB 事务引擎,请先备份并由管理员迁移表引擎'); + } + } + // MySQL 在 autocommit 下静默忽略 SAVEPOINT;必须在写入前验证它实际存在。 + // 不用 BEGIN 探测:它会隐式提交已有事务。也兼容调用方 SET autocommit=0。 + $write('SAVEPOINT ' . $savepoint); + try { + // 直接检查驱动错误码,避免 Typecho PDO 包装丢失 MySQL errno。 + $result = $handle->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + if ($result === false) { + $code = $handle instanceof \PDO ? (int)($handle->errorInfo()[1] ?? 0) : (int)$handle->errno; + throw new \RuntimeException('无法验证字段同步保存点', $code); + } + } catch (\Throwable $e) { + $code = $e instanceof \PDOException ? (int)($e->errorInfo[1] ?? 0) : (int)$e->getCode(); + if ($code !== 1305) { throw $e; } + $write('BEGIN'); + $owned = true; + } + } elseif (strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false) { + if ($handle instanceof \PDO) { + $active = $handle->inTransaction(); + } elseif (function_exists('pg_transaction_status')) { + $status = pg_transaction_status($handle); + if (!in_array($status, [PGSQL_TRANSACTION_IDLE, PGSQL_TRANSACTION_INTRANS], true)) { + throw new \RuntimeException('数据库事务不可用,请先结束当前失败的事务'); + } + $active = $status === PGSQL_TRANSACTION_INTRANS; + } else { + throw new \RuntimeException('无法安全检测数据库事务状态'); + } + if (!$active) { $write('BEGIN'); $owned = true; } + } elseif (strpos($adapter, 'sqlite') !== false) { + // SQLite BEGIN 不会隐式提交已有事务。PDO 可直接检测;原生 SQLite3 + // 没有跨版本可用的状态 API,只接受明确的“已有事务”错误,其余错误上抛。 + if (!($handle instanceof \PDO) || !$handle->inTransaction()) { + try { @$write('BEGIN'); $owned = true; } + catch (\Throwable $e) { + if (strpos($e->getMessage(), 'cannot start a transaction within a transaction') === false) { throw $e; } + } + } + } else { + throw new \RuntimeException('当前数据库适配器不支持安全的字段同步事务'); + } + // 嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT;只有 owned 才能删除实体文件。 + $started = false; + try { + $write('SAVEPOINT ' . $savepoint); + $started = true; + $operation($write); + $write('RELEASE SAVEPOINT ' . $savepoint); + if ($owned) { $write('COMMIT'); } + } catch (\Throwable $e) { + try { + if ($owned) { $write('ROLLBACK'); } + elseif ($started) { + $write('ROLLBACK TO SAVEPOINT ' . $savepoint); + $write('RELEASE SAVEPOINT ' . $savepoint); + } + } catch (\Throwable $rollbackError) { + // 保留最初的错误,同时明确记录连接丢失等导致无法确认回滚的情况。 + \TypechoPlugin\InfinityTime\Plugin::log('transaction rollback failed: ' . $rollbackError->getMessage()); + } + throw $e; + } + return $owned; + } } diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 1c73a6c..0e0c55b 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -344,6 +344,43 @@ public static function setImageMeta(int $rowId, string $title, string $desc, str ])->where('id = ?', $rowId)); } + /** 图片元数据与公开聚合字段一并保存,失败保持两者的旧版本。 */ + public static function editImage(int $rowId, int $cid, string $title, string $desc, string $address): bool + { + $changed = false; + self::fieldTransaction(static function (callable $write) use ($rowId, $cid, $title, $desc, $address, &$changed): void { + $db = \Typecho\Db::get(); + $row = $db->fetchRow($write($db->select('id')->from(self::table())->where('id = ?', $rowId)->where('cid = ?', $cid)->limit(1))); + if (!$row) { return; } + $write($db->update(self::table())->rows(['title' => $title, 'desc' => $desc, 'address' => $address]) + ->where('id = ?', $rowId)->where('cid = ?', $cid)); + self::writePostFields($cid, false, $write); + $changed = true; + }, true); + return $changed; + } + + /** 校验完整当前清单后原子排序;中途失败不会留下半套顺序。 */ + public static function sortImages(int $cid, array $rowIds): void + { + self::fieldTransaction(static function (callable $write) use ($cid, $rowIds): void { + $db = \Typecho\Db::get(); + foreach ($rowIds as $id) { + if (!is_int($id) || $id <= 0) { throw new \DomainException('图片列表包含无效标识,请刷新后重试'); } + } + $rows = $db->fetchAll($write($db->select('id')->from(self::table())->where('cid = ?', $cid))); + $existing = array_map(static function ($row) { return (int)$row['id']; }, $rows); + $requested = $rowIds; sort($requested); sort($existing); + if ($requested !== $existing || count($rowIds) !== count(array_unique($rowIds))) { + throw new \DomainException('图片列表已变化或包含其他图集图片,请刷新后重试'); + } + foreach ($rowIds as $order => $rowId) { + $write($db->update(self::table())->rows(['sort' => $order])->where('id = ?', $rowId)->where('cid = ?', $cid)); + } + self::writePostFields($cid, false, $write); + }, true); + } + /** 重建后同步实际输出尺寸、大小与响应式变体路径。 */ public static function updateVariants(int $rowId, array $result): void { @@ -368,96 +405,15 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void }); } - /** 共用字段同步事务协议;返回 true 仅表示本方法已提交自己开启的事务。 */ + /** 共用事务协议;返回 true 仅表示本方法已提交自己开启的事务。 */ private static function fieldTransaction(callable $operation, bool $mutatesImages = false, bool $mutatesContents = false): bool { self::lockMedia(); $db = \Typecho\Db::get(); - $adapter = strtolower($db->getAdapterName()); - $handle = $db->selectDb(\Typecho\Db::WRITE); - $write = static function ($query) { - // 所有同步语句编译后固定走主库;不需要 INSERT 的生成主键。 - return Database::query(is_string($query) ? $query : $query->prepare((string)$query)); - }; - $savepoint = 'infinitytime_fields_' . bin2hex(random_bytes(8)); - $owned = false; - $mysql = strpos($adapter, 'mysql') !== false; - if ($mysql) { - // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。 - $tables = [$db->getPrefix() . 'fields']; - if ($mutatesImages) { $tables[] = self::table(); } - if ($mutatesContents) { $tables[] = $db->getPrefix() . 'contents'; } - foreach ($tables as $name) { - $query = $db->select(); - $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($name)); - $table = $db->fetchRow($write($sql)); - if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) { - throw new \RuntimeException('图片字段同步需要相关表使用 InnoDB 事务引擎,请先备份并由管理员迁移表引擎'); - } - } - // MySQL 在 autocommit 下静默忽略 SAVEPOINT;必须在写入前验证它实际存在。 - // 不用 BEGIN 探测:它会隐式提交已有事务。也兼容调用方 SET autocommit=0。 - $write('SAVEPOINT ' . $savepoint); - try { - // 直接检查驱动错误码,避免 Typecho PDO 包装丢失 MySQL errno。 - $result = $handle->query('ROLLBACK TO SAVEPOINT ' . $savepoint); - if ($result === false) { - $code = $handle instanceof \PDO ? (int)($handle->errorInfo()[1] ?? 0) : (int)$handle->errno; - throw new \RuntimeException('无法验证字段同步保存点', $code); - } - } catch (\Throwable $e) { - $code = $e instanceof \PDOException ? (int)($e->errorInfo[1] ?? 0) : (int)$e->getCode(); - if ($code !== 1305) { throw $e; } - $write('BEGIN'); - $owned = true; - } - } elseif (strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false) { - if ($handle instanceof \PDO) { - $active = $handle->inTransaction(); - } elseif (function_exists('pg_transaction_status')) { - $status = pg_transaction_status($handle); - if (!in_array($status, [PGSQL_TRANSACTION_IDLE, PGSQL_TRANSACTION_INTRANS], true)) { - throw new \RuntimeException('数据库事务不可用,请先结束当前失败的事务'); - } - $active = $status === PGSQL_TRANSACTION_INTRANS; - } else { - throw new \RuntimeException('无法安全检测数据库事务状态'); - } - if (!$active) { $write('BEGIN'); $owned = true; } - } elseif (strpos($adapter, 'sqlite') !== false) { - // SQLite BEGIN 不会隐式提交已有事务。PDO 可直接检测;原生 SQLite3 - // 没有跨版本可用的状态 API,只接受明确的“已有事务”错误,其余错误上抛。 - if (!($handle instanceof \PDO) || !$handle->inTransaction()) { - try { @$write('BEGIN'); $owned = true; } - catch (\Throwable $e) { - if (strpos($e->getMessage(), 'cannot start a transaction within a transaction') === false) { throw $e; } - } - } - } else { - throw new \RuntimeException('当前数据库适配器不支持安全的字段同步事务'); - } - // 嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT;只有 owned 才能删除实体文件。 - $started = false; - try { - $write('SAVEPOINT ' . $savepoint); - $started = true; - $operation($write); - $write('RELEASE SAVEPOINT ' . $savepoint); - if ($owned) { $write('COMMIT'); } - } catch (\Throwable $e) { - try { - if ($owned) { $write('ROLLBACK'); } - elseif ($started) { - $write('ROLLBACK TO SAVEPOINT ' . $savepoint); - $write('RELEASE SAVEPOINT ' . $savepoint); - } - } catch (\Throwable $rollbackError) { - // 保留最初的错误,同时明确记录连接丢失等导致无法确认回滚的情况。 - Plugin::log('field sync rollback failed: ' . $rollbackError->getMessage()); - } - throw $e; - } - return $owned; + $tables = [$db->getPrefix() . 'fields']; + if ($mutatesImages) { $tables[] = self::table(); } + if ($mutatesContents) { $tables[] = $db->getPrefix() . 'contents'; } + return Database::transaction($operation, $tables); } /** diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index 588ef93..e27b196 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -194,14 +194,7 @@ function pp_clear_site_avatar(string $url, ?string $keepAbs = null): void function pp_set_field(int $cid, string $name, string $value): void { - $db = Db::get(); - $prefix = $db->getPrefix(); - Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name)); - if ($value !== '') { - Database::query($db->insert($prefix . 'fields')->rows([ - 'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value, - ])); - } + AdminRepository::setField($cid, $name, $value); } function pp_data_file(): string @@ -550,17 +543,15 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $cid = (int)($_POST['cid'] ?? 0); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } - if ($cid > 0) { - $title = trim((string)($_POST['title'] ?? '')); - if ($title !== '') { - Database::query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid)); - } - pp_set_field($cid, 'device', trim((string)($_POST['device'] ?? ''))); - pp_set_field($cid, 'tags', trim((string)($_POST['tags'] ?? ''))); - pp_set_field($cid, 'location', trim((string)($_POST['address'] ?? ''))); - if ($ajax) { pp_reply_json(true, _t('已更新图集信息')); } - pp_reply(_t('已更新图集信息')); + try { + AdminWorkflow::updateAlbum($cid, (int)$user->uid, pp_is_admin(), pp_can_publish(), $_POST); + } catch (\Throwable $e) { + Plugin::log('update_album: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图集信息暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图集信息暂未确认保存,请刷新核对后重试', 'error'); } + if ($ajax) { pp_reply_json(true, _t('已更新图集信息')); } + pp_reply(_t('已更新图集信息')); } if ($action === 'set_image_meta') { @@ -572,9 +563,15 @@ function pp_exif_summary(array $exif): string if ($rowId > 0) { $row = AdminRepository::readRow($db->select('cid')->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限修改该图片'); } - ImageRepository::setImageMeta($rowId, $title, $desc, $addr); - if ($row && (int)$row['cid'] > 0) { - ImageRepository::syncPostFields((int)$row['cid']); + try { + if (!ImageRepository::editImage($rowId, (int)$row['cid'], $title, $desc, $addr)) { + if ($ajax) { pp_reply_json(false, '图片已变化,请刷新后重试', [], 409); } + pp_reply('图片已变化,请刷新后重试', 'error'); + } + } catch (\Throwable $e) { + Plugin::log('set_image_meta: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图片信息暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图片信息暂未确认保存,请刷新核对后重试', 'error'); } } else { pp_deny('没有权限修改该图片'); @@ -587,23 +584,22 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $cid = (int)($_POST['cid'] ?? 0); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } - $rowIds = array_map('intval', (array)($_POST['rowIds'] ?? [])); - $existing = array_map(static function ($row) { return (int)$row['id']; }, AdminRepository::readAll($db->select('id')->from(ImageRepository::table())->where('cid = ?', $cid), true)); - $requested = $rowIds; sort($requested); sort($existing); - if ($requested !== $existing || count($rowIds) !== count(array_unique($rowIds))) { - pp_deny('图片列表已变化或包含其他图集图片,请刷新后重试'); - } - $order = 0; - foreach ($rowIds as $rid) { - $rid = (int)$rid; - if ($rid <= 0) { - continue; + $rowIds = []; + foreach ((array)($_POST['rowIds'] ?? []) as $value) { + if (!(is_int($value) || (is_string($value) && ctype_digit($value))) || (int)$value <= 0) { + pp_deny('图片列表包含无效标识,请刷新后重试'); } - Database::query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid)); - $order++; + $rowIds[] = (int)$value; } - if ($cid > 0) { - ImageRepository::syncPostFields($cid); + try { + ImageRepository::sortImages($cid, $rowIds); + } catch (\DomainException $e) { + if ($ajax) { pp_reply_json(false, $e->getMessage(), [], 409); } + pp_reply($e->getMessage(), 'error'); + } catch (\Throwable $e) { + Plugin::log('sort_images: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图片顺序暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图片顺序暂未确认保存,请刷新核对后重试', 'error'); } if ($ajax) { pp_reply_json(true, _t('已保存图片顺序')); } pp_reply(_t('已保存图片顺序')); diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index f7aa5f0..cf3bfcf 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -1,5 +1,12 @@ # 更新日志 +## 未发布 + +- 分页并发调用共享同次请求;缺少 AbortController、响应体挂起或取消时也能及时结束等待,迟到响应不污染下一次请求。 +- 后台单字段/多字段替换、图集标题与元数据、图片元数据与聚合字段、整组排序统一原子保存;失败保留旧值,外层事务不被提前提交。 +- 草稿创建复用同一保存点协议并在写入前检查 MySQL 相关表事务引擎;新增真实多数据库故障注入回归。 + + ## 1.14.1 - 单图及整图集删除先在同一事务提交记录与聚合字段,再安全清理未被其他记录/历史 URL 引用的文件;失败保留恢复机会。 From 1a0a874bf44300a6875fe345967449f3bea06df2 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:44:24 -0700 Subject: [PATCH 3/5] =?UTF-8?q?=E7=BB=88=E6=AD=A2=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=AE=9E=E9=99=85=E8=BF=9B=E7=A8=8B=EF=BC=8C?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=B8=85=E7=90=86=E4=B8=B4=E6=97=B6=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=B9=B6=E9=AA=8C=E8=AF=81Windows=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 21 ++ README.md | 4 + tests/media-command.php | 48 +++++ tests/media-temp.php | 63 ++++++ .../InfinityTime/Lib/MediaProcessor.php | 179 +++++++++--------- usr/themes/InfinityTime/update.md | 3 + 6 files changed, 227 insertions(+), 91 deletions(-) create mode 100644 tests/media-command.php create mode 100644 tests/media-temp.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7207189..da77e71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: php tests/image-delete.php php tests/media-lifetime.php php tests/media-gd.php + php tests/media-command.php + php tests/media-temp.php php tests/maintenance-state.php for mode in rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do php tests/maintenance-panel.php "$mode" @@ -170,3 +172,22 @@ jobs: for adapter in $adapters; do php tests/typecho-integration.php "$adapter" done + + process-portability: + runs-on: windows-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: gd, fileinfo + coverage: none + - name: Windows 外部进程与临时资源回归 + run: | + php tests/media-command.php + php tests/media-temp.php diff --git a/README.md b/README.md index a0e48fe..7a33c4a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ 分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本轮保留版本1.14.1,不自动合并、发布或部署。 +外部转换用 PHP 7.4+ 的 argv 数组直接启动实际工具,避免只终止中间 shell 而让工具继续写入;日志输出被直接丢弃至系统空设备。Windows 禁止 .bat/.cmd 包装,须配置直接可执行的工具。该管理仅覆盖直接子进程,工具自行创建的后代仍须由主机/工具管理。临时 JPEG 使用随机目录(POSIX 0700;Windows 遵循主机临时目录 ACL),统一清理所有多帧结果;清理被权限或文件系统阻止时记录警告,不掩盖原转换错误。 + ### v1.14.1 性能与可靠性加固 维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。 @@ -100,6 +102,8 @@ php tests/field-sync.php php tests/image-delete.php php tests/media-lifetime.php php tests/media-gd.php +php tests/media-command.php +php tests/media-temp.php php tests/maintenance-state.php php tests/maintenance-panel.php php tests/admin-workflow.php diff --git a/tests/media-command.php b/tests/media-command.php new file mode 100644 index 0000000..3a25ea8 --- /dev/null +++ b/tests/media-command.php @@ -0,0 +1,48 @@ +setAccessible(true); +$root = sys_get_temp_dir() . '/infinity-command-' . bin2hex(random_bytes(5)); +mkdir($root, 0700); +$checks = 0; +function commandCheck(bool $ok, string $message): void { global $checks; if (!$ok) { throw new RuntimeException($message); } $checks++; } +try { + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'exit(0);'], 5) === 0, '保留成功退出码'); + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'exit(7);'], 5) === 7, '保留失败退出码'); + $args = ['带 空格/路径', '"quoted"', "O'Reilly", '$HOME;echo nope', 'back\\slash', '[0]', '百分号%COMSPEC%']; + $record = $root . '/argv.json'; + $cmd = [PHP_BINARY, '-r', 'file_put_contents($argv[1],json_encode(array_slice($argv,2),JSON_UNESCAPED_UNICODE));', $record]; + commandCheck($method->invoke(null, array_merge($cmd, $args), 5) === 0, '包含引号和特殊字符的命令成功'); + commandCheck(json_decode(file_get_contents($record), true) === $args, 'argv不经shell解释或破坏'); + $memory = memory_get_peak_usage(true); + $started = microtime(true); + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'for($i=0;$i<512;$i++){fwrite(STDOUT,str_repeat("a",16384));fwrite(STDERR,str_repeat("b",16384));}'], 5) === 0, '16MB双通道输出不会管道阻塞'); + commandCheck(memory_get_peak_usage(true) - $memory < 8 * 1024 * 1024, '子进程日志不累积到PHP内存'); + $marker = $root . '/late-marker'; + $started = microtime(true); $timedOut = false; + try { $method->invoke(null, [PHP_BINARY, '-r', 'usleep(2000000);file_put_contents($argv[1],"alive");', $marker], 1); } + catch (RuntimeException $e) { + $timedOut = strpos($e->getMessage(), '超时') !== false; + commandCheck(strpos($e->getMessage(), $marker) === false, '错误不暴露参数或私有临时路径'); + } + commandCheck($timedOut, '超时必须抛出明确错误'); + commandCheck(microtime(true) - $started < 3, '超时结束等待'); + usleep(1300000); + commandCheck(!is_file($marker), '实际工具子进程已停止,不会在父流程返回后继续写入'); + foreach ([[], [PHP_BINARY, "invalid\0argument"]] as $invalid) { + $rejected = false; try { $method->invoke(null, $invalid, 1); } catch (InvalidArgumentException $e) { $rejected = true; } + commandCheck($rejected, '非法命令在启动前拒绝'); + } + if (PHP_OS_FAMILY === 'Windows') { + foreach (['tool.bat', 'tool.cmd'] as $batch) { + $rejected = false; try { $method->invoke(null, [$batch, 'argument'], 1); } catch (RuntimeException $e) { $rejected = true; } + commandCheck($rejected, 'Windows批处理包装不进入隐式命令解释器'); + } + } + echo '真实转换子进程:' . $checks . ' 项检查通过(PHP ' . PHP_VERSION . ' / ' . PHP_OS_FAMILY . ")\n"; +} finally { + foreach (glob($root . '/*') as $file) { unlink($file); } + rmdir($root); +} diff --git a/tests/media-temp.php b/tests/media-temp.php new file mode 100644 index 0000000..e048bd6 --- /dev/null +++ b/tests/media-temp.php @@ -0,0 +1,63 @@ +setAccessible(true); + foreach (['success', 'fallback', 'failure', 'invalid', 'timeout'] as $mode) { + if ($name === 'decodeAvif' && $mode === 'fallback') { continue; } + putenv('IT_CODEC_CASE=' . $mode); $ticks = 0; $before = count($producedDirs); $thrown = false; + try { $decoded = $method->invoke(null, $source); tempCheck(imagesx($decoded) === 3 && imagesy($decoded) === 2, '有效JPEG由GD正常读取'); unset($decoded); } + catch (RuntimeException $e) { $thrown = true; } + tempCheck($thrown === in_array($mode, ['failure','invalid','timeout'], true), $name . ' 的成功/失败契约正确:' . $mode); + tempCheck(count($producedDirs) > $before, '实际执行外部工具fixture'); + foreach (array_slice($producedDirs, $before) as $directory) { tempCheck(!is_dir($directory), '成功、异常、超时和多帧输出均清理整个私有目录'); } + tempCheck(file_get_contents($source) === 'private original fixture', '不会修改或删除源文件'); + } + } + if (PHP_OS_FAMILY !== 'Windows') { foreach ($privateModes as $mode) { tempCheck($mode === 0700, 'POSIX转换目录仅当前用户可读写'); } } + echo '转换临时资源:' . $checks . " 项检查通过\n"; + } finally { + putenv('IT_CODEC_CASE'); + foreach (glob($fixtureRoot . '/*') as $file) { unlink($file); } + rmdir($fixtureRoot); + } +} diff --git a/usr/plugins/InfinityTime/Lib/MediaProcessor.php b/usr/plugins/InfinityTime/Lib/MediaProcessor.php index e547fab..dec19d2 100644 --- a/usr/plugins/InfinityTime/Lib/MediaProcessor.php +++ b/usr/plugins/InfinityTime/Lib/MediaProcessor.php @@ -393,39 +393,25 @@ private static function decodeHeic(string $srcPath) } } - $jpeg = tempnam(sys_get_temp_dir(), 'pp_heic_'); - $done = false; - - if ($tools['magick']) { - $code = self::runCmd(escapeshellarg($tools['magick']) . ' ' . escapeshellarg($srcPath) . '[0] -colorspace sRGB -quality 92 ' . escapeshellarg($jpeg), 30); - $done = $code === 0 && is_file($jpeg) && filesize($jpeg) > 0; - } - if (!$done && $tools['convert']) { - $code = self::runCmd(escapeshellarg($tools['convert']) . ' ' . escapeshellarg($srcPath) . '[0] -colorspace sRGB -quality 92 ' . escapeshellarg($jpeg), 30); - $done = $code === 0 && is_file($jpeg) && filesize($jpeg) > 0; - } - if (!$done && $tools['heif']) { - $bin = is_string($tools['heif']) ? $tools['heif'] : 'heif-convert'; - // heif-convert 要求输出名带 .jpg;多图时会在扩展名前加 -N(base.jpg -> base-1.jpg) - $code = self::runCmd(escapeshellarg($bin) . ' ' . escapeshellarg($srcPath) . ' ' . escapeshellarg($jpeg . '.jpg'), 30); - $found = self::findProducedJpeg($jpeg); - $done = $code === 0 && $found !== null; - if ($done) { - $jpeg = $found; + $directory = self::conversionDirectory(); + try { + $jpeg = null; + foreach (['magick', 'convert'] as $tool) { + if (!$tools[$tool]) { continue; } + $candidate = $directory . '/' . $tool . '.jpg'; + $code = self::runCmd([$tools[$tool], $srcPath . '[0]', '-colorspace', 'sRGB', '-quality', '92', $candidate], 30); + clearstatcache(true, $candidate); + if ($code === 0 && is_file($candidate) && filesize($candidate) > 0) { $jpeg = $candidate; break; } } - } - - if (!$done || !$jpeg || !is_file($jpeg) || filesize($jpeg) === 0) { - @unlink($jpeg); - throw new \RuntimeException( - 'HEIC 解码失败:Imagick 无法读取该文件,且没有可用的 heif-convert/ImageMagick(或服务器 PHP 禁用了 exec/shell_exec)。' - . ' 请改传 JPG/PNG/WebP;若必须用 HEIC,请在插件设置填入 heif-convert 路径并确保 PHP 未禁用 exec。' - ); - } - - $img = imagecreatefromjpeg($jpeg); - @unlink($jpeg); - return $img; + if ($jpeg === null && $tools['heif']) { + $bin = is_string($tools['heif']) ? $tools['heif'] : 'heif-convert'; + $base = $directory . '/heif'; + $code = self::runCmd([$bin, $srcPath, $base . '.jpg'], 30); + if ($code === 0) { $jpeg = self::findProducedJpeg($base); } + } + if ($jpeg === null) { throw new \RuntimeException('HEIC 解码失败,请检查转换工具支持,或改传 JPG/PNG/WebP'); } + return self::readConvertedJpeg($jpeg); + } finally { self::clearConversionDirectory($directory); } } /** @@ -473,78 +459,89 @@ private static function decodeAvif(string $srcPath) return imagecreatefromstring($data); } - $jpeg = tempnam(sys_get_temp_dir(), 'pp_avif_'); - if ($tools['magick']) { - $code = self::runCmd(escapeshellarg($tools['magick']) . ' ' . escapeshellarg($srcPath) . '[0] -quality 92 ' . escapeshellarg($jpeg), 30); - if ($code === 0 && is_file($jpeg) && filesize($jpeg) > 0) { - $img = imagecreatefromjpeg($jpeg); - @unlink($jpeg); - return $img; + $directory = self::conversionDirectory(); + try { + $jpeg = $directory . '/decoded.jpg'; + if ($tools['magick']) { + $code = self::runCmd([$tools['magick'], $srcPath . '[0]', '-quality', '92', $jpeg], 30); + clearstatcache(true, $jpeg); + if ($code === 0 && is_file($jpeg) && filesize($jpeg) > 0) { return self::readConvertedJpeg($jpeg); } } + throw new \RuntimeException('AVIF 需要 ImageMagick(安装 libavif)支持'); + } finally { self::clearConversionDirectory($directory); } + } + + /** 临时解码结果可能含原图信息;使用随机私有目录隔离多帧和失败残留。 */ + private static function conversionDirectory(): string + { + $directory = rtrim(sys_get_temp_dir(), '/\\') . '/infinitytime-codec-' . bin2hex(random_bytes(16)); + if (!@mkdir($directory, 0700)) { throw new \RuntimeException('无法创建转换临时目录,请检查磁盘空间与临时目录权限'); } + return $directory; + } + + private static function readConvertedJpeg(string $path) + { + if (!is_file($path) || is_link($path)) { throw new \RuntimeException('转换工具未输出有效的普通图片文件'); } + // HEIC 等格式可能无法在初始 getimagesize 阶段读取尺寸,解码成 JPEG 后再次限制像素。 + self::assertImageSize($path); + $image = @imagecreatefromjpeg($path); + if (!$image) { throw new \RuntimeException('转换工具输出不是有效的 JPEG 图片'); } + return $image; + } + + private static function clearConversionDirectory(string $directory): void + { + if (is_link($directory)) { @unlink($directory); return; } + if (!is_dir($directory)) { return; } + try { + foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) { + if ($file->isFile() || $file->isLink()) { @unlink($file->getPathname()); } + } + if (!@rmdir($directory)) { \TypechoPlugin\InfinityTime\Plugin::log('conversion temporary directory cleanup incomplete'); } + } catch (\Throwable $e) { + // 清理失败不能掩盖原来的转换错误;私有目录留给管理员核查。 + \TypechoPlugin\InfinityTime\Plugin::log('conversion temporary directory cleanup failed'); } - @unlink($jpeg); - throw new \RuntimeException('AVIF 需要 ImageMagick(安装 libavif)支持'); } /** - * 以带超时的方式运行外部命令。 - * - * PHP 的 exec() 会无限期阻塞在外部子进程上(max_execution_time 并不覆盖系统调用等待), - * 一旦 magick/heif-convert 卡死会拖住整个 PHP 工作进程。这里改用 proc_open 并强制超时, - * 超时即 kill,避免后端“网页卡死”。 - * - * @param string $cmd 拼接好的命令行(参数已 escapeshellarg) - * @param int $timeout 超时秒数 - * @return int 退出码 - * @throws \RuntimeException 进程无法启动或超时 + * 直接启动编码器,不经过额外 shell;超时终止的是实际工具进程。 + * PHP 7.4+ 支持 argv 数组。输出原本即被丢弃,直接接系统空设备, + * 避免海量日志占内存及 Windows 管道无法可靠非阻塞造成的等待。 + * 仅管理直接子进程;工具自行创建的后代进程仍受工具/主机进程管理约束。 */ - private static function runCmd(string $cmd, int $timeout = 60): int + private static function runCmd(array $command, int $timeout = 60): int { - if (!function_exists('proc_open')) { - throw new \RuntimeException('proc_open 被禁用,无法执行外部命令'); + foreach (['proc_open', 'proc_get_status', 'proc_terminate', 'proc_close'] as $function) { + if (!function_exists($function)) { throw new \RuntimeException('外部命令管理函数被禁用,无法安全启动转换工具'); } } - $proc = @proc_open($cmd, [ - 0 => ['file', '/dev/null', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], $pipes); - if (!is_resource($proc)) { - throw new \RuntimeException('无法启动外部命令: ' . $cmd); + if (!$command || $timeout < 1) { throw new \InvalidArgumentException('外部命令或超时参数无效'); } + foreach ($command as $argument) { + if (!is_string($argument) || strpos($argument, "\0") !== false) { throw new \InvalidArgumentException('外部命令参数无效'); } } - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - + if ($command[0] === '' || (PHP_OS_FAMILY === 'Windows' && preg_match('/\.(?:bat|cmd)$/i', $command[0]))) { + throw new \RuntimeException('请配置可直接执行的转换工具,不支持 Windows 批处理包装'); + } + $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + $proc = @proc_open($command, [0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w']], $pipes, null, null, ['bypass_shell' => true]); + if (!is_resource($proc)) { throw new \RuntimeException('无法启动转换工具:' . basename($command[0])); } $start = microtime(true); - $status = proc_get_status($proc); - while ($status['running']) { - // 尽量抽空子进程输出,避免管道填满导致子进程阻塞 - @stream_get_contents($pipes[1]); - @stream_get_contents($pipes[2]); - - if ((microtime(true) - $start) > $timeout) { - proc_terminate($proc, 9); - usleep(300000); - if (proc_get_status($proc)['running']) { + $exit = -1; + try { + do { + $status = proc_get_status($proc); + if (!$status['running']) { $exit = (int)($status['exitcode'] ?? -1); break; } + if (microtime(true) - $start >= $timeout) { proc_terminate($proc, 9); - usleep(100000); + throw new \RuntimeException('外部转换工具超时(' . $timeout . 's):' . basename($command[0])); } - @fclose($pipes[1]); - @fclose($pipes[2]); - proc_close($proc); - throw new \RuntimeException('外部命令超时(' . $timeout . 's): ' . $cmd); - } - usleep(50000); - $status = proc_get_status($proc); - } - - $exit = isset($status['exitcode']) ? (int)$status['exitcode'] : -1; - @fclose($pipes[1]); - @fclose($pipes[2]); - $closed = proc_close($proc); - if ($closed !== -1) { - $exit = $closed; + usleep(50000); + } while (true); + } finally { + if (proc_get_status($proc)['running']) { proc_terminate($proc, 9); } + $closed = proc_close($proc); } - return $exit; + return $closed !== -1 ? $closed : $exit; } /** 依据 EXIF Orientation(1-8)对位图做旋转/镜像修正(GD 不会自动处理)。 */ diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index cf3bfcf..731b7fd 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -2,6 +2,9 @@ ## 未发布 +- 外部转换工具使用 argv 直接启动,超时终止实际工具进程;输出直连系统空设备,不因大量日志或 Windows 阻塞管道拖住请求。 +- HEIC/AVIF 外部转换明确使用 JPEG 扩展名,输出在随机私有目录隔离;成功、失败、超时及多帧输出统一清理,转换后再次检查像素上限。 + - 分页并发调用共享同次请求;缺少 AbortController、响应体挂起或取消时也能及时结束等待,迟到响应不污染下一次请求。 - 后台单字段/多字段替换、图集标题与元数据、图片元数据与聚合字段、整组排序统一原子保存;失败保留旧值,外层事务不被提前提交。 - 草稿创建复用同一保存点协议并在写入前检查 MySQL 相关表事务引擎;新增真实多数据库故障注入回归。 From 7247146fd63767428fb2fac8cd87135817ce34b0 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:52:43 -0700 Subject: [PATCH 4/5] =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E8=B6=85=E5=A4=A7?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E8=BE=93=E5=87=BA=E5=8F=8A=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=9B=9E=E5=BD=92=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?Windows=E6=B5=8B=E8=AF=95=E9=80=90=E9=A1=B9=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8D=B3=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 +++ tests/media-temp.php | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da77e71..3b60e64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,9 @@ jobs: extensions: gd, fileinfo coverage: none - name: Windows 外部进程与临时资源回归 + shell: pwsh run: | php tests/media-command.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } php tests/media-temp.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/media-temp.php b/tests/media-temp.php index e048bd6..f467837 100644 --- a/tests/media-temp.php +++ b/tests/media-temp.php @@ -1,7 +1,7 @@ setAccessible(true); - foreach (['success', 'fallback', 'failure', 'invalid', 'timeout'] as $mode) { + foreach (['success', 'fallback', 'failure', 'invalid', 'timeout', 'oversized', 'cleanup'] as $mode) { if ($name === 'decodeAvif' && $mode === 'fallback') { continue; } - putenv('IT_CODEC_CASE=' . $mode); $ticks = 0; $before = count($producedDirs); $thrown = false; + putenv('IT_CODEC_CASE=' . $mode); $ticks = 0; $before = count($producedDirs); $thrown = false; $error = ''; $logsBefore = count(\TypechoPlugin\InfinityTime\Plugin::$logs); try { $decoded = $method->invoke(null, $source); tempCheck(imagesx($decoded) === 3 && imagesy($decoded) === 2, '有效JPEG由GD正常读取'); unset($decoded); } - catch (RuntimeException $e) { $thrown = true; } - tempCheck($thrown === in_array($mode, ['failure','invalid','timeout'], true), $name . ' 的成功/失败契约正确:' . $mode); + catch (RuntimeException $e) { $thrown = true; $error = $e->getMessage(); } + tempCheck($thrown === in_array($mode, ['failure','invalid','timeout','oversized'], true), $name . ' 的成功/失败契约正确:' . $mode); tempCheck(count($producedDirs) > $before, '实际执行外部工具fixture'); - foreach (array_slice($producedDirs, $before) as $directory) { tempCheck(!is_dir($directory), '成功、异常、超时和多帧输出均清理整个私有目录'); } + if ($mode === 'oversized') { tempCheck(strpos($error, '上限') !== false, '转换JPEG超过60MP时在GD解码之前明确拒绝'); } + foreach (array_slice($producedDirs, $before) as $directory) { + if ($mode === 'cleanup') { + tempCheck(is_dir($directory) && count(array_filter(glob($directory . '/*'), 'is_file')) === 0, '清理受阻只保留私有目录,不把成功转换误报失败'); + tempCheck(count(\TypechoPlugin\InfinityTime\Plugin::$logs) > $logsBefore, '临时目录清理受阻必须有日志'); + unlink($directory . '/nested/retained'); rmdir($directory . '/nested'); rmdir($directory); + } else { tempCheck(!is_dir($directory), '成功、异常、超时和多帧输出均清理整个私有目录'); } + } tempCheck(file_get_contents($source) === 'private original fixture', '不会修改或删除源文件'); } } From 7876470efe762a8145bb9bca57b92cf87a51f0db Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:08:48 -0700 Subject: [PATCH 5/5] =?UTF-8?q?=E5=87=86=E5=A4=871.14.2=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=A1=A5=E4=B8=81=E5=8F=8A=E5=8F=97=E6=8E=A7?= =?UTF-8?q?=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-guard.cjs | 10 +++++----- .github/workflows/release.yml | 6 +++--- README.md | 6 +++--- tests/release-guards.cjs | 10 +++++----- usr/plugins/InfinityTime/Plugin.php | 4 ++-- usr/themes/InfinityTime/index.php | 2 +- usr/themes/InfinityTime/releases.json | 2 +- usr/themes/InfinityTime/update.md | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs index 768c372..e73fcdc 100644 --- a/.github/scripts/release-guard.cjs +++ b/.github/scripts/release-guard.cjs @@ -1,11 +1,11 @@ 'use strict'; -// 仅用于本次已授权的 v1.14.1;失败或部分发布必须人工核查,绝不覆盖资产。 +// 仅用于本次已授权的 v1.14.2;失败或部分发布必须人工核查,绝不覆盖资产。 const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const { execFileSync } = require('node:child_process'); -const VERSION = '1.14.1'; +const VERSION = '1.14.2'; const TAG = `v${VERSION}`; const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip']; function insist(ok, message) { if (!ok) throw new Error(message); } @@ -22,9 +22,9 @@ function guardRun(event, repository, mainSha, run, workflow) { return mainSha; } function guardVersions(theme, plugin, release) { - insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.1'); - insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.1'); - insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.1'); + insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.14.2'); + insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.2'); + insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.2'); } function guardExisting(tagSha, release, sha, digests) { if (!tagSha && !release) return 'create'; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c95233..7e52737 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,8 +64,8 @@ jobs: dist/infinitytime-plugin.zip body_path: dist/RELEASE_NOTES.md - # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.1。 - release-1-14-1: + # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.2。 + release-1-14-2: if: >- github.event_name == 'workflow_run' && github.repository == 'Thregren/InfinityTime' && @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 concurrency: - group: release-v1.14.1 + group: release-v1.14.2 cancel-in-progress: false steps: - name: 在 checkout 前验证可信 main 和 CI 身份 diff --git a/README.md b/README.md index 7a33c4a..29d224c 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ ## 功能特性 -### 开发分支:异常恢复加固(未发布) +### v1.14.2 异常恢复与编辑一致性加固 -分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本轮保留版本1.14.1,不自动合并、发布或部署。 +分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本版在完整 CI 与发布校验通过后提供主题和插件更新包;不会自动部署线上站点。 外部转换用 PHP 7.4+ 的 argv 数组直接启动实际工具,避免只终止中间 shell 而让工具继续写入;日志输出被直接丢弃至系统空设备。Windows 禁止 .bat/.cmd 包装,须配置直接可执行的工具。该管理仅覆盖直接子进程,工具自行创建的后代仍须由主机/工具管理。临时 JPEG 使用随机目录(POSIX 0700;Windows 遵循主机临时目录 ACL),统一清理所有多帧结果;清理被权限或文件系统阻止时记录警告,不掩盖原转换错误。 @@ -243,4 +243,4 @@ git tag v1.7.0 && git push origin v1.7.0 ### 本次固定版本发布 -1.14.1 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.1 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 +1.14.2 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.2 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs index d580fd4..64d567b 100644 --- a/tests/release-guards.cjs +++ b/tests/release-guards.cjs @@ -30,20 +30,20 @@ for (const [name, change] of [ ['workflow ID 不符', f => { f.workflow.id++; }], ]) test(`拒绝${name}`, () => { const f = fixture(); change(f); assert.throws(() => check(f)); }); test('拒绝 main 已移动', () => assert.throws(() => check(fixture(), 'b'.repeat(40)))); -test('允许唯一版本 1.14.1', () => guardVersions('@version 1.14.1', "const VERSION = '1.14.1'", '{"tag_name":"1.14.1"}')); +test('允许唯一版本 1.14.2', () => guardVersions('@version 1.14.2', "const VERSION = '1.14.2'", '{"tag_name":"1.14.2"}')); for (const index of [0, 1, 2]) test(`拒绝版本字段 ${index} 不一致或未来版本`, () => { - const values = ['@version 1.14.1', "const VERSION = '1.14.1'", '{"tag_name":"1.14.1"}']; - values[index] = values[index].replace('1.14.1', '1.14.2'); + const values = ['@version 1.14.2', "const VERSION = '1.14.2'", '{"tag_name":"1.14.2"}']; + values[index] = values[index].replace('1.14.2', '1.14.3'); assert.throws(() => guardVersions(...values)); }); const digests = Object.fromEntries(ASSETS.map(name => [name, 'f'.repeat(64)])); -function complete() { return { tag_name: 'v1.14.1', draft: false, prerelease: false, assets: ASSETS.map(name => ({ name, state: 'uploaded', size: 42, digest: `sha256:${digests[name]}` })) }; } +function complete() { return { tag_name: 'v1.14.2', draft: false, prerelease: false, assets: ASSETS.map(name => ({ name, state: 'uploaded', size: 42, digest: `sha256:${digests[name]}` })) }; } test('仅 tag/release 均不存在时允许创建', () => assert.equal(guardExisting(null, null, sha, digests), 'create')); test('已有完整一致发布仅幂等成功', () => assert.equal(guardExisting(sha, complete(), sha, digests), 'complete')); for (const [name, tag, release] of [ ['tag SHA 冲突', 'b'.repeat(40), complete()], ['tag-only 部分发布', sha, null], ['release 无 tag', null, complete()], ['draft 部分发布', sha, { ...complete(), draft: true }], ['prerelease', sha, { ...complete(), prerelease: true }], - ['错误 tag', sha, { ...complete(), tag_name: 'v1.14.2' }], ['缺资产', sha, { ...complete(), assets: complete().assets.slice(1) }], + ['错误 tag', sha, { ...complete(), tag_name: 'v1.14.3' }], ['缺资产', sha, { ...complete(), assets: complete().assets.slice(1) }], ['额外资产', sha, { ...complete(), assets: [...complete().assets, complete().assets[0]] }], ['摘要冲突', sha, { ...complete(), assets: complete().assets.map(a => ({ ...a, digest: 'sha256:wrong' })) }], ['空包', sha, { ...complete(), assets: complete().assets.map(a => ({ ...a, size: 0 })) }], diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index 49e6bdb..988b7b2 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -4,7 +4,7 @@ * * @package InfinityTime * @author InfinityTime - * @version 1.14.1 + * @version 1.14.2 * @link https://github.com/infinitytime/infinitytime */ @@ -27,7 +27,7 @@ // Typecho versions instead of being misclassified as an instant plugin. class Plugin implements \Typecho_Plugin_Interface { - public const VERSION = '1.14.1'; + public const VERSION = '1.14.2'; public const MENU_NAME = 'InfinityTime'; // 统一默认(“恢复默认/写入”)配置,避免各处写死不同数值 public const DEFAULT_QUALITY = 76; // 缩略图质量 diff --git a/usr/themes/InfinityTime/index.php b/usr/themes/InfinityTime/index.php index 82554f0..19faf44 100755 --- a/usr/themes/InfinityTime/index.php +++ b/usr/themes/InfinityTime/index.php @@ -3,7 +3,7 @@ * 一款简约的相册主题 * @package 无限时光 * @author InfinityTime - * @version 1.14.1 + * @version 1.14.2 * @link https://github.com/InfinityTime/InfinityTime */ ?> diff --git a/usr/themes/InfinityTime/releases.json b/usr/themes/InfinityTime/releases.json index 7875a65..dc628c9 100644 --- a/usr/themes/InfinityTime/releases.json +++ b/usr/themes/InfinityTime/releases.json @@ -1,4 +1,4 @@ { - "tag_name": "1.14.1" + "tag_name": "1.14.2" } diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index 731b7fd..951c541 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -1,6 +1,6 @@ # 更新日志 -## 未发布 +## 1.14.2 - 外部转换工具使用 argv 直接启动,超时终止实际工具进程;输出直连系统空设备,不因大量日志或 Windows 阻塞管道拖住请求。 - HEIC/AVIF 外部转换明确使用 JPEG 扩展名,输出在随机私有目录隔离;成功、失败、超时及多帧输出统一清理,转换后再次检查像素上限。