diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs index e73fcdc..44e8510 100644 --- a/.github/scripts/release-guard.cjs +++ b/.github/scripts/release-guard.cjs @@ -1,11 +1,11 @@ 'use strict'; -// 仅用于本次已授权的 v1.14.2;失败或部分发布必须人工核查,绝不覆盖资产。 +// 仅用于本次已授权的 v1.15.0;失败或部分发布必须人工核查,绝不覆盖资产。 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.2'; +const VERSION = '1.15.0'; 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.2'); - insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.2'); - insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.2'); + insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.15.0'); + insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.15.0'); + insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.15.0'); } function guardExisting(tagSha, release, sha, digests) { if (!tagSha && !release) return 'create'; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b60e64..72e18e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,12 +45,14 @@ jobs: php tests/schema-sqlite.php php tests/field-sync.php php tests/image-delete.php + php tests/media-version.php php tests/media-lifetime.php php tests/media-gd.php php tests/media-command.php php tests/media-temp.php + php tests/health-diagnostics.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 + for mode in scan-failure retry-image retry-fields retry-mixed retry-resync retry-repeat retry-finished retry-stale retry-empty retry-unfinished retry-other-job errors-page errors-stale resync-failure 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" done php tests/admin-workflow.php @@ -65,6 +67,7 @@ jobs: node tests/maintenance-client.cjs node --test tests/release-guards.cjs node tests/gallery.cjs + node tests/data-saver.cjs node tests/gallery-loading.cjs node tests/gallery-navigation.cjs @@ -108,6 +111,9 @@ jobs: - name: 后台上传浏览器回归 if: ${{ !cancelled() }} run: node tests/admin-upload.cjs --browser + - name: 维护失败恢复浏览器回归 + if: ${{ !cancelled() }} + run: node tests/maintenance-browser.cjs typecho-integration: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e52737..d5b0d7c 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.2。 - release-1-14-2: + # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.15.0。 + release-1-15-0: 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.2 + group: release-v1.15.0 cancel-in-progress: false steps: - name: 在 checkout 前验证可信 main 和 CI 身份 diff --git a/README.md b/README.md index 29d224c..58cda56 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ ## 功能特性 +### v1.15.0 维护恢复与省流浏览 + +- 维护失败明细按图片或相册保留,支持每页50项查看与仅重试失败项。编码失败和字段同步失败分开恢复;旧任务未保存的明细不会被虚构。 +- 照片加载可跟随系统省流建议或手动切换。省流关闭自动翻页与邻图预取,仍可主动看图和加载更多;隐藏页面暂停额外加载并释放预取引用。 +- 重建后公开派生图带新的缓存标识,稳定照片ID与数据库物理路径保持不变。CDN必须将查询参数纳入缓存键;已打开页面仍需刷新获取新URL。 +- 管理员可查看只读健康检查和隔离恢复演练步骤。它不会自动修改目录、表引擎、服务器保护规则或生产数据;发现编码器不代表该工具已通过真实图片解码验证。 + ### v1.14.2 异常恢复与编辑一致性加固 分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本版在完整 CI 与发布校验通过后提供主题和插件更新包;不会自动部署线上站点。 @@ -243,4 +250,4 @@ git tag v1.7.0 && git push origin v1.7.0 ### 本次固定版本发布 -1.14.2 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.2 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 +1.15.0 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.15.0 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 diff --git a/tests/admin-panel.php b/tests/admin-panel.php index 5d883ab..7e53c8e 100644 --- a/tests/admin-panel.php +++ b/tests/admin-panel.php @@ -97,6 +97,7 @@ function _t($message): string { return $message; } throw new RuntimeException('分页、明确发布或原生上传幂等字段缺失'); } if (strpos($html, 'data-loaded="1"') !== false) { throw new RuntimeException('摘要首屏不应加载图片详情'); } + if (strpos($html, '只读健康检查') === false || strpos($html, '在隔离环境做一次恢复演练') === false) { throw new RuntimeException('管理员健康检查与恢复步骤缺失'); } echo '后台模板:' . count($forms[1]) . " 个原生表单令牌、分页、草稿与按需详情检查通过\n"; } finally { foreach (['header.php', 'menu.php', 'footer.php'] as $name) { unlink($adminDir . '/' . $name); } diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php index 533b39e..ac85754 100644 --- a/tests/admin-workflow.php +++ b/tests/admin-workflow.php @@ -76,6 +76,7 @@ public static function opt($name, $default) { return $default; } namespace TypechoPlugin\InfinityTime\Lib { function is_uploaded_file($path): bool { return is_file($path); } class ImageRepository { + public static function uploadRoot(): string { return sys_get_temp_dir(); } public static bool $locked = false; public static bool $busy = false; public static bool $failSync = false; diff --git a/tests/data-saver.cjs b/tests/data-saver.cjs new file mode 100644 index 0000000..97f8560 --- /dev/null +++ b/tests/data-saver.cjs @@ -0,0 +1,39 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/data-saver.js'), 'utf8'); +function run(saved, network, blocked = false) { + const listeners = {}, networkListeners = {}, changes = []; + const control = { value: '', parentNode: { hidden: true }, addEventListener(type, fn) { this[type] = fn; } }; + const status = {}; + const document = { hidden: false, getElementById: id => id.endsWith('-status') ? status : control }; + const connection = network && { ...network, addEventListener(type, fn) { networkListeners[type] = fn; } }; + const localStorage = { getItem() { if (blocked) throw Error('blocked'); return saved; }, setItem(key, value) { if (blocked) throw Error('blocked'); saved = value; } }; + const window = { addEventListener(type, fn) { listeners[type] = fn; }, dispatchEvent(event) { changes.push(event.detail); } }; + vm.runInNewContext(source, { document, window, navigator: { connection }, localStorage, CustomEvent: class { constructor(type, options) { this.detail = options.detail; } } }); + return { window, document, control, status, changes, connection, listeners, networkListeners, select(value) { control.value = value; control.change(); } }; +} +for (const network of [null, {}, { saveData: false, effectiveType: '4g' }]) { + const h = run(null, network); + assert.equal(h.window.InfinityDataSaver.isEnabled(), false); + assert.equal(h.control.parentNode.hidden, false); + h.select('on'); assert.equal(h.window.InfinityDataSaver.allowBackground(), false); + h.select('off'); assert.equal(h.window.InfinityDataSaver.allowBackground(), true); + h.document.hidden = true; assert.equal(h.window.InfinityDataSaver.allowBackground(), false); +} +for (const network of [{ saveData: true }, { effectiveType: '2g' }, { effectiveType: 'slow-2g' }]) { + const h = run('auto', network); + assert.equal(h.window.InfinityDataSaver.isEnabled(), true); + h.select('off'); assert.equal(h.window.InfinityDataSaver.isEnabled(), false, '显式正常模式覆盖系统省流'); + h.select('auto'); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +} +const blocked = run('off', { saveData: true }, true); +blocked.select('off'); assert.equal(blocked.window.InfinityDataSaver.isEnabled(), false, '存储拒绝不影响当前选择'); +const h = run('on', {}); +h.listeners.storage({ key: 'unrelated', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +h.listeners.storage({ key: 'InfinityTime.dataSaver', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), false); +h.connection.saveData = true; h.select('auto'); h.networkListeners.change(); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +h.listeners.storage({ key: null, newValue: null }); assert.equal(h.control.value, 'auto'); +assert.equal(run('invalid', {}).control.value, 'auto'); +console.log('省流策略:系统建议、显式覆盖、存储拒绝、跨页同步和后台暂停通过'); diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs index c631f89..6514101 100644 --- a/tests/gallery-loading-browser.cjs +++ b/tests/gallery-loading-browser.cjs @@ -19,8 +19,8 @@ function card(id, cid, images, ids, options = {}) { data-exif='[]' data-descs='[]' data-addresses='[]'>Album ${id}

Album ${id}

`; } function pageHtml(cards, next = '/page2', navigation = false) { - return `
${cards}
- ${['init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => ``).join('')}`; + return `
${cards}
+ ${['data-saver', 'init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].concat(navigation ? ['gallery-navigation'] : []).map(n => ``).join('')}`; } const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p-1', 'p-2', 'p-3', 'p-4'], { variants: [variants('a'), variants('b'), variants('pano')], panos: [0, 0, 1, 0] @@ -100,10 +100,20 @@ const pixel = ' assert.equal(await page.evaluate(target => InfinityGallery.open(target), { albumId, photoId }), true); } async function ready(name) { - await page.waitForFunction(source => { - const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); - return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1'; - }, url(name)); + try { + await page.waitForFunction(source => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1'; + }, url(name)); + } catch (error) { + console.error('图片就绪超时诊断', await page.evaluate(() => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return { hidden: document.hidden, src: img && img.getAttribute('src'), selected: img && img.currentSrc, + complete: img && img.complete, width: img && img.naturalWidth, opacity: img && img.style.opacity, + popup: p && p.className, switching: p && p.__switching, events: window.__galleryEvents.slice(-8) }; + })); + throw error; + } await page.waitForTimeout(500); } async function close() { @@ -289,9 +299,31 @@ const pixel = ' await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo')); await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); await page.goto(origin + '/?album=1&photo=p-5'); await ready('other-month'); - await page.keyboard.press('Escape'); + // 关闭直接分享链接会真正重载相册页,不能只等 replaceState 改了地址。 + await Promise.all([page.waitForEvent('load'), page.keyboard.press('Escape')]); await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo')); assert.equal(new URL(page.url()).searchParams.get('album'), '1'); + // 省流模式在真实浏览器中禁止自动分页和相邻大图预取,主动操作仍能成功。 + await page.goto(origin); + await page.selectOption('#gallery-data-saver', 'on'); + await page.reload(); + assert.equal(await page.locator('#gallery-data-saver').inputValue(), 'on', '刷新保留显式省流选择'); + const pageRequestsBefore = requests.filter(path => path === '/page2').length; + await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); window.dispatchEvent(new Event('scroll')); }); + await page.waitForTimeout(150); + assert.equal(requests.filter(path => path === '/page2').length, pageRequestsBefore, '省流禁止自动下一页'); + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '省流允许手动加载下一页'); + await page.evaluate(html => { + document.querySelector('#waterfall').insertAdjacentHTML('beforeend', html); + window.__rebindPoptrox(); + }, card('saving', 99, [url('saving-current'), url('saving-next')], ['p-991', 'p-992'])); + await open('99', 'p-991'); await ready('saving-current'); + await page.waitForTimeout(150); + assert.equal(requests.some(path => path.includes('saving-next')), false, '省流不预取邻图'); + await page.keyboard.press('ArrowRight'); await ready('saving-next'); + await close(); + await page.selectOption('#gallery-data-saver', 'off'); + assert.equal(await page.evaluate(() => InfinityDataSaver.isEnabled()), false, '可以恢复正常模式'); assert.deepEqual(errors, []); await context.close(); console.log(`图册加载、降级和重试回归测试通过:${browserName} ${viewport.width}px`); diff --git a/tests/health-diagnostics.php b/tests/health-diagnostics.php new file mode 100644 index 0000000..c09ea3c --- /dev/null +++ b/tests/health-diagnostics.php @@ -0,0 +1,25 @@ + false]); + if (count($report) !== 8 || scandir($dir) !== $before) { throw new RuntimeException('自检条目或只读约束失败'); } + $byName = array_column($report, null, 'name'); + if ($byName['私有原图保护']['status'] !== 'manual' || $byName['数据库事务']['status'] !== 'manual' || $byName['HEIC/AVIF 工具']['status'] !== 'warning') { throw new RuntimeException('未知结果不能冒充验证通过'); } + $missing = HealthDiagnostics::collect(new HealthDb(), $dir . '/missing', ['heif' => '/private/tool']); + if ($missing[0]['status'] !== 'warning' || $missing[1]['status'] !== 'manual') { throw new RuntimeException('缺失目录需清楚报告'); } + if (strpos(json_encode($missing), '/private/tool') !== false || strpos(json_encode($report), $dir) !== false) { throw new RuntimeException('诊断不得暴露服务器路径'); } + if (is_dir($dir . '/missing')) { throw new RuntimeException('不能自动创建目录'); } + define('__TYPECHO_ADMIN__', true); + $user = new class { public function pass($role, $return): bool { return false; } }; + ob_start(); + include __DIR__ . '/../usr/plugins/InfinityTime/health-panel.php'; + if (ob_get_clean() !== '') { throw new RuntimeException('非管理员不得读取健康报告'); } + echo "只读健康检查:目录/磁盘/工具/手工核验边界及无写入检查通过\n"; +} finally { rmdir($dir); } diff --git a/tests/maintenance-browser.cjs b/tests/maintenance-browser.cjs new file mode 100644 index 0000000..b40e690 --- /dev/null +++ b/tests/maintenance-browser.cjs @@ -0,0 +1,82 @@ +// 真实浏览器运行后台脚本;所有请求拦截,无线上数据或媒体操作。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const playwright = require('playwright'); +const engine = process.env.PLAYWRIGHT_BROWSER || 'chromium'; +assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); +const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); +const parentId = 'a'.repeat(32), childId = 'b'.repeat(32); +const failures = Array.from({ length: 123 }, (_, i) => ({ task: i === 122 ? 'resync' : 'rebuild', id: i + 1, cid: 10, reason: i === 0 ? '编码失败' : '图集字段同步失败;重试时只同步字段' })); +let state = { job: 'rebuild', job_id: parentId, total: 200, done: 200, failed: 123, finished: true }; +function response(offset = 0) { return { ...state, failures: failures.slice(offset, offset + 50), failure_total: failures.length, failure_offset: offset, failure_next: offset + 50 < failures.length ? offset + 50 : null }; } +function html() { + const job = state.finished && state.failed ? response() : { ...state, failures: [], failure_total: 0 }; + return `
+ +
+ +
`; +} +(async () => { + const browser = await playwright[engine].launch({ headless: true, ...(engine === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + try { + const page = await browser.newPage(); + const errors = [], requests = [], pending = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('https://maintenance.test/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname; + if (request.method() === 'POST') { + const fields = {}; + for (const match of request.postData().matchAll(/name="([^"]+)"\r\n\r\n([^\r]*)/g)) fields[match[1]] = match[2]; + requests.push(fields); pending.push(route); return; + } + await route.fulfill({ contentType: path === '/admin.js' ? 'text/javascript' : 'text/html', body: path === '/admin.js' ? source : html() }); + }); + async function nextRequest(count) { + await page.waitForFunction(() => document.querySelector('#pp-retry-failures').disabled || document.querySelector('#pp-failure-next').disabled); + const deadline = Date.now() + 5000; + while (requests.length < count && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(requests.length, count, '请求数量应精确且没有重复'); + assert.equal(requests[count - 1]._, 'test-token'); + return requests[count - 1]; + } + async function reply(data) { await pending.shift().fulfill({ contentType: 'application/json', body: JSON.stringify(data) }); } + await page.goto('https://maintenance.test/'); + assert.equal(await page.locator('#pp-failure-list li').count(), 50, '刷新后立即显示有界失败清单'); + assert.equal(await page.locator('#pp-failure-list img').count(), 0, '错误内容只能显示为文本'); + await page.evaluate(() => { document.querySelector('#pp-failure-next').click(); document.querySelector('#pp-failure-next').click(); }); + assert.deepEqual(await nextRequest(1), { job: 'rebuild', mode: 'errors', job_id: parentId, offset: '50', ajax: '1', action: 'maintenance', _: 'test-token' }); + await reply(response(50)); + await page.waitForFunction(() => document.querySelector('#pp-failure-summary').textContent.includes('51–100')); + await page.click('#pp-failure-next'); await nextRequest(2); await reply(response(100)); + await page.waitForFunction(() => document.querySelector('#pp-failure-summary').textContent.includes('101–123')); + assert.equal(await page.locator('#pp-failure-list li').count(), 23); + assert.match(await page.locator('#pp-failure-list li').last().textContent(), /相册 ID 123/); + await page.evaluate(() => { document.querySelector('#pp-retry-failures').click(); document.querySelector('#pp-retry-failures').click(); document.querySelector('[data-run="resync"]').click(); }); + assert.equal((await nextRequest(3)).mode, 'retry'); + assert.equal(requests[2].job_id, parentId); + // 服务端已建立子任务,但首次响应丢失。 + state = { job: 'rebuild', job_id: childId, retry_of: parentId, total: 123, done: 3, failed: 0, finished: false }; + await pending.shift().abort('failed'); + await page.waitForFunction(() => !document.querySelector('#pp-retry-failures').disabled); + await page.click('#pp-retry-failures'); + assert.equal((await nextRequest(4)).mode, 'retry'); + assert.equal(requests[3].job_id, parentId, '丢失响应后必须重复同一原任务重试请求'); + await reply({ ...state, failures: [], failure_total: 0 }); + assert.equal((await nextRequest(5)).mode, 'poll'); + assert.equal(requests[4].job_id, childId); + await pending.shift().abort('failed'); + await page.waitForFunction(() => !document.querySelector('[data-run="rebuild"]').disabled); + await page.reload(); + await page.getByRole('button', { name: '继续', exact: true }).click(); + assert.equal((await nextRequest(6)).mode, 'resume'); + assert.equal(requests[5].job_id, childId, '刷新恢复继续同一个子任务身份'); + state = { ...state, done: 123, finished: true }; + await reply({ ...state, failures: [], failure_total: 0 }); + await page.waitForFunction(() => document.querySelector('#pp-msg-rebuild').textContent.includes('✓ 完成')); + assert.equal(await page.locator('#pp-maintenance-failures').isVisible(), false); + assert.deepEqual(errors, [], '后台脚本无浏览器异常'); + console.log(`维护真实浏览器 ${engine}:失败分页、文本安全、重复点击、丢失响应和刷新续跑通过`); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/maintenance-client.cjs b/tests/maintenance-client.cjs index bb58184..7a6d500 100644 --- a/tests/maintenance-client.cjs +++ b/tests/maintenance-client.cjs @@ -4,10 +4,13 @@ const fs = require('node:fs'); const vm = require('node:vm'); const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); class Element { - constructor() { this.style = {}; this.listeners = {}; this.disabled = false; this.textContent = ''; } + constructor(job = '') { this.job = job; this.children = []; this.style = {}; this.listeners = {}; this.disabled = false; this.textContent = ''; } + set textContent(value) { this._text = value; this.children = []; } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + appendChild(child) { this.children.push(child); child.parentNode = this; } addEventListener(name, listener) { this.listeners[name] = listener; } emit(name) { this.listeners[name]?.({ target: this }); } - getAttribute(name) { return name === 'data-run' ? 'rebuild' : null; } + getAttribute(name) { return name === 'data-run' ? this.job || 'rebuild' : null; } querySelector(selector) { return selector === 'button' ? (this.button ||= new Element()) : null; } insertBefore(child) { this.child = child; child.parentNode = this; } removeChild(child) { assert.equal(this.child, child, '继续任务提示应移除自身'); this.child = null; } @@ -20,12 +23,13 @@ class FormDataMock { } const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); }; function harness(job = null) { - const button = new Element(), bar = new Element(), message = new Element(), container = new Element(); - const nodes = { '[data-run="rebuild"]': button, '#pp-bar-rebuild': bar, '#pp-msg-rebuild': message, '.container.typecho-page-main': container }; + const button = new Element('rebuild'), otherButton = new Element('resync'), bar = new Element(), message = new Element(), container = new Element(); + const nodes = { '[data-run="rebuild"]': button, '[data-run="resync"]': otherButton, '#pp-bar-rebuild': bar, '#pp-msg-rebuild': message, '.container.typecho-page-main': container }; + for (const id of ['pp-maintenance-failures', 'pp-failure-summary', 'pp-failure-list', 'pp-failure-prev', 'pp-failure-next', 'pp-retry-failures']) nodes['#' + id] = new Element(); const listeners = {}, requests = [], timers = [], replies = []; const document = { querySelector: selector => nodes[selector] || null, - querySelectorAll: selector => selector === '[data-run]' ? [button] : [], + querySelectorAll: selector => selector === '[data-run]' ? [button, otherButton] : [], addEventListener: (name, listener) => { listeners[name] = listener; }, createElement: () => new Element(), }; @@ -38,7 +42,7 @@ function harness(job = null) { }, }); listeners.DOMContentLoaded(); - return { button, bar, message, container, requests, timers, + return { button, otherButton, bar, message, container, requests, timers, nodes, reply(data, ok = true) { replies.shift().resolve({ ok, json: async () => data }); }, reject() { replies.shift().reject(new Error('模拟网络中断')); }, malformed() { replies.shift().resolve({ ok: true, json: async () => { throw new Error('JSON'); } }); }, @@ -64,7 +68,7 @@ function harness(job = null) { assert.equal(h.requests[1].body.get('job_id'), 'current-job', '轮询必须发送服务器返回的任务身份'); h.reply({ job_id: 'current-job', total: 5, done: 5, failed: 1, finished: true }); await flush(); assert.match(h.message.textContent, /失败 1/, '部分失败计数必须可见'); - assert.match(h.message.textContent, /1 项失败,请查看日志/, '完成时必须显示失败处理提示'); + assert.match(h.message.textContent, /1 项失败,请查看下方清单/, '完成时必须显示失败处理提示'); assert.equal(h.button.disabled, false, '完成后恢复启动按钮'); assert.equal(h.timers.length, 0, '完成后不再轮询'); h.button.emit('click'); @@ -96,5 +100,60 @@ function harness(job = null) { assert.equal(broken.requests.length, 2, '故障必须释放运行状态以允许重试:' + fault); broken.reply({ total: 0, done: 0, failed: 0, finished: true }); await flush(); } - console.log('维护前端:开始/轮询/续跑身份、批次、重复点击、失败可见及错误恢复检查通过'); + const parentId = 'a'.repeat(32), childId = 'b'.repeat(32); + const failure = (id, task = 'rebuild') => ({ task, id, cid: 10, reason: task === 'resync' ? '图集字段同步失败;重试时只同步字段' : '图片编码失败,请检查原图与转换工具' }); + const completed = { job: 'rebuild', job_id: parentId, total: 6, done: 6, failed: 2, finished: true, + failures: [failure(1), failure(10, 'resync')], failure_total: 2, failure_offset: 0, failure_next: null }; + const retry = harness(completed); + assert.equal(retry.nodes['#pp-maintenance-failures'].hidden, false, '刷新后完成任务的失败清单保留'); + assert.equal(retry.nodes['#pp-failure-list'].children.length, 2, '刷新后立即展示所有当前页失败项'); + assert.match(retry.nodes['#pp-failure-list'].children[0].textContent, /图片 ID 1 \/ 相册 ID 10/, '图片失败显示图片与相册身份'); + assert.match(retry.nodes['#pp-failure-list'].children[1].textContent, /相册 ID 10.*只同步字段/, '字段失败显示单独恢复方式'); + const retryButton = retry.nodes['#pp-retry-failures']; + retryButton.emit('click'); retryButton.emit('click'); retry.otherButton.emit('click'); + assert.equal(retry.requests.length, 1, '重试双击及其他维护按钮不能并行启动'); + assert.equal(retry.requests[0].body.get('mode'), 'retry'); + assert.equal(retry.requests[0].body.get('job_id'), parentId, '失败重试必须带原任务身份'); + assert.equal(retry.otherButton.disabled, true, '重试时禁用所有维护启动按钮'); + retry.reject(); await flush(); + retryButton.emit('click'); + assert.equal(retry.requests[1].body.get('mode'), 'retry', '重试响应丢失必须保留重试语义'); + assert.equal(retry.requests[1].body.get('job_id'), parentId, '重试响应丢失必须保留同一原任务身份'); + const unfinished = { job: 'rebuild', job_id: childId, retry_of: parentId, total: 2, done: 1, failed: 0, failures: [], failure_total: 0, finished: false }; + retry.reply(unfinished); await flush(); retry.poll(); retry.reject(); await flush(); + retry.button.emit('click'); + assert.equal(retry.requests[3].body.get('mode'), 'poll', '轮询失败再次点击不能变成全库开始'); + assert.equal(retry.requests[3].body.get('job_id'), childId, '轮询恢复必须使用子任务身份'); + retry.reply({ ...unfinished, done: 2, finished: true }); await flush(); + assert.equal(retry.nodes['#pp-maintenance-failures'].hidden, true, '失败全部恢复后隐藏空清单'); + const refreshRetry = harness(unfinished); + refreshRetry.container.child.querySelector('button').emit('click'); + assert.equal(refreshRetry.requests[0].body.get('mode'), 'resume', '重试任务刷新后提供续跑'); + assert.equal(refreshRetry.requests[0].body.get('job_id'), childId, '刷新续跑必须保留服务器注入的完整身份'); + refreshRetry.reply({ ...unfinished, done: 2, finished: true }); await flush(); + + const pages = harness({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 50 }, (_, i) => failure(i + 1)), failure_next: 50 }); + pages.nodes['#pp-failure-next'].emit('click'); pages.nodes['#pp-failure-next'].emit('click'); + assert.equal(pages.requests.length, 1, '翻页重复点击只能发一个请求'); + assert.equal(pages.requests[0].body.get('mode'), 'errors', '翻页是独立只读维护模式'); + assert.equal(pages.requests[0].body.get('job_id'), parentId, '翻页必须绑定当前任务'); + assert.equal(pages.requests[0].body.get('offset'), 50); + pages.reply({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 50 }, (_, i) => failure(i + 51)), failure_offset: 50, failure_next: 100 }); await flush(); + assert.equal(pages.nodes['#pp-failure-list'].children.length, 50, '翻页替换当前页,DOM保持有界'); + assert.match(pages.nodes['#pp-failure-summary'].textContent, /共 123 项失败,显示 51–100 项/); + pages.nodes['#pp-failure-next'].emit('click'); + pages.reply({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 23 }, (_, i) => failure(i + 101)), failure_offset: 100, failure_next: null }); await flush(); + assert.equal(pages.nodes['#pp-failure-list'].children.length, 23); + assert.match(pages.nodes['#pp-failure-list'].children[22].textContent, /图片 ID 123/); + assert.equal(pages.nodes['#pp-failure-next'].disabled, true, '末页禁用下一页'); + pages.nodes['#pp-failure-prev'].emit('click'); pages.reject(); await flush(); + pages.nodes['#pp-failure-prev'].emit('click'); + assert.equal(pages.requests[3].body.get('offset'), 50, '分页网络失败后可以重试同一页'); + pages.reply({ ...completed, failure_offset: 0 }); await flush(); + const legacy = harness({ job: 'rebuild', job_id: parentId, failed: 4, finished: true, unlisted_failed: 4, failure_total: 0 }); + assert.match(legacy.nodes['#pp-failure-summary'].textContent, /4 项来自旧版任务/); + assert.equal(legacy.nodes['#pp-retry-failures'].disabled, true, '缺少旧版明细不能发起空重试'); + const panelSource = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/panel.php'), 'utf8'); + assert.match(panelSource, /'job' => \$ppJobState/, '后台必须注入带job_id的完整有界任务响应,不能重新遗漏身份'); + console.log('维护前端:身份、重复点击、失败清单、分页、刷新及定向重试响应丢失恢复检查通过'); })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/maintenance-panel.php b/tests/maintenance-panel.php index 9a7ddd4..d34b247 100644 --- a/tests/maintenance-panel.php +++ b/tests/maintenance-panel.php @@ -49,6 +49,7 @@ class Db { public array $reads = []; public array $operations = []; public bool $failDelete = false; + public bool $failScan = false; public string $adapter = 'Pdo_SQLite'; public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } public static function get(): self { return self::$instance; } @@ -62,6 +63,7 @@ public function fetchRow($query) { if ($query instanceof \PDOStatement) { return public function fetchAll($query): array { if ($query instanceof \PDOStatement) { return $query->fetchAll(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetchAll(\PDO::FETCH_ASSOC); } public function query($query, $op = self::READ) { $this->operations[] = ['sql' => (string)$query, 'op' => $op, 'object' => is_object($query)]; + if ($this->failScan && strpos((string)$query, 'original <>') !== false) { throw new \RuntimeException('SQL SELECT original FROM private_table /private/server/database.sqlite'); } if (preg_match('/^SELECT /i', (string)$query)) { $this->reads[] = (string)$query; return $this->pdo->query((string)$query); } if ($this->failDelete && strpos((string)$query, 'DELETE FROM "admin_contents"') === 0) { throw new \RuntimeException('模拟文章删除故障'); } $affected = $this->pdo->exec((string)$query); @@ -112,7 +114,7 @@ public static function updateVariants(int $id, array $result): void { public static function syncPostFields(int $cid): void { if (!self::$locked) { throw new \RuntimeException('字段同步必须持有媒体锁'); } self::$synced[] = $cid; - if (self::$failSync) { throw new \RuntimeException('模拟字段同步故障'); } + if (self::$failSync) { throw new \RuntimeException('模拟字段同步故障 /private/server/database.sqlite'); } } } class MediaProcessor { @@ -120,7 +122,7 @@ class MediaProcessor { public static int $calls = 0; public static function process(...$args): array { self::$calls++; - if (self::$fail) { throw new \RuntimeException('模拟编码故障'); } + if (self::$fail) { throw new \RuntimeException('模拟编码故障 /private/server/original.png'); } return ['width' => 1, 'height' => 1]; } } @@ -139,9 +141,11 @@ function removeFixture(string $dir): void { rmdir($dir); } $mode = $argv[1] ?? 'rebuild-sync-failure'; - $modes = ['rebuild-sync-failure', 'rebuild-success', 'rebuild-batch', 'rebuild-restart', 'rebuild-resume', 'rebuild-start-reuse', + $modes = ['scan-failure', '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']; + 'delete-non-plugin', 'delete-rollback', 'lease-conflict', + 'retry-image', 'retry-fields', 'retry-mixed', 'retry-resync', 'retry-repeat', 'retry-finished', + 'retry-stale', 'retry-empty', 'retry-unfinished', 'retry-other-job', 'errors-page', 'errors-stale', 'resync-failure']; expect(in_array($mode, $modes, true), '未知测试模式:' . $mode); $source = dirname(__DIR__) . '/usr/plugins/InfinityTime'; require $source . '/Lib/Database.php'; @@ -194,6 +198,37 @@ function removeFixture(string $dir): void { if ($mode === 'lease-conflict') { State::write($root . '/data/job.lock', ['uid' => 7, 'job' => 'cleanup', 'time' => time()]); } + $retryModes = ['retry-image', 'retry-fields', 'retry-mixed', 'retry-resync', 'retry-repeat', 'retry-finished', + 'retry-stale', 'retry-empty', 'retry-unfinished', 'retry-other-job', 'errors-page', 'errors-stale']; + if (in_array($mode, $retryModes, true)) { + $state['done'] = 3; $state['finished'] = true; + $db->query("INSERT INTO admin_infinitytime_images VALUES (2,20,'/original.png','/full2.webp','/thumb2.webp'),(3,30,'/original.png','/full3.webp','/thumb3.webp')"); + if ($mode === 'retry-resync') { $state['job'] = 'resync'; } + if (!in_array($mode, ['retry-fields', 'retry-resync', 'retry-empty', 'errors-page'], true)) { State::failure($state, 'rebuild', 1, 10, 'conversion'); } + if (in_array($mode, ['retry-fields', 'retry-mixed'], true)) { State::failure($state, 'resync', 30, 30, 'field_sync'); } + if ($mode === 'retry-resync') { State::failure($state, 'resync', 10, 10, 'field_sync'); } + if (in_array($mode, ['retry-repeat', 'retry-finished'], true)) { State::failure($state, 'rebuild', 3, 30, 'conversion'); } + if ($mode === 'errors-page') { + for ($id = 1; $id <= 123; $id++) { State::failure($state, 'rebuild', $id, 10, 'conversion'); } + } + if (in_array($mode, ['retry-unfinished', 'retry-other-job'], true)) { + $state['done'] = 1; $state['finished'] = false; + State::write(State::listFile($root . '/data', $state), [1, 2, 3]); + } + State::save($root . '/data', $state); + $_POST['job'] = $state['job']; $_POST['mode'] = 'retry'; $_POST['job_id'] = $oldId; + if ($mode === 'retry-repeat' || $mode === 'retry-finished') { + [$state] = State::open($root . '/data', 'rebuild', 'retry', $oldId, static function () { throw new RuntimeException('不得重扫'); }); + $state['done'] = $mode === 'retry-repeat' ? 1 : 2; + $state['finished'] = $mode === 'retry-finished'; + State::save($root . '/data', $state); + } + if ($mode === 'retry-stale' || $mode === 'errors-stale') { $_POST['job_id'] = str_repeat('b', 32); } + if ($mode === 'retry-other-job') { $_POST['job'] = 'cleanup'; $_POST['mode'] = 'start'; unset($_POST['job_id']); } + if (strpos($mode, 'errors-') === 0) { $_POST['mode'] = 'errors'; $_POST['offset'] = 100; } + } + if ($mode === 'scan-failure') { $db->failScan = true; } + if ($mode === 'resync-failure') { $_POST['job'] = 'resync'; Images::$failSync = true; } $beforeState = is_file($root . '/data/job.json') ? file_get_contents($root . '/data/job.json') : null; $beforeLock = is_file($root . '/data/job.lock') ? file_get_contents($root . '/data/job.lock') : null; $beforeFiles = glob($root . '/data/*'); @@ -210,7 +245,32 @@ function removeFixture(string $dir): void { expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '快照、实时读取与事务必须使用主库连接'); } } - if ($mode === 'lease-conflict') { + expect(strpos($output, '/private/server/') === false && strpos($output, $root) === false, '失败响应不能泄漏服务器路径'); + if (in_array($mode, ['retry-stale', 'retry-empty', 'retry-unfinished', 'retry-other-job', 'errors-stale'], true)) { + expect($json['ok'] === false, '不合法重试必须明确拒绝'); + expect(file_get_contents($root . '/data/job.json') === $beforeState && glob($root . '/data/*') === $beforeFiles, '拒绝不能覆盖或增添检查点'); + expect(Media::$calls === 0 && Images::$synced === [] && $db->operations === [], '拒绝必须先于任何扫描或媒体操作'); + } elseif ($mode === 'scan-failure') { + expect(http_response_code() === 500 && $json['ok'] === false && strpos($output, 'private') === false && strpos($output, 'SELECT') === false, '候选扫描异常不得泄漏SQL或服务器路径'); + expect(strpos($json['msg'], '服务器日志') !== false && Media::$calls === 0, '扫描失败应提供安全恢复提示'); + } elseif ($mode === 'errors-page') { + expect($json['ok'] === true && $json['failure_total'] === 123 && $json['failure_offset'] === 100 && count($json['failures']) === 23 && $json['failure_next'] === null, '最后一页失败必须完整可达'); + expect($json['failures'][22]['id'] === 123 && $json['job_id'] === $oldId, '分页必须保留原任务身份且包含最后项目'); + expect(file_get_contents($root . '/data/job.json') === $beforeState && glob($root . '/data/*') === $beforeFiles && $db->operations === [], '翻页不能处理媒体或改写状态'); + } elseif (in_array($mode, ['retry-image', 'retry-fields', 'retry-mixed', 'retry-resync', 'retry-repeat', 'retry-finished'], true)) { + expect($json['finished'] === true && $json['failed'] === 0 && $json['retry_of'] === $oldId && $json['job_id'] !== $oldId, '重试必须完成独立子任务并保留原任务身份'); + $expectedImages = $mode === 'retry-repeat' ? [3] : (in_array($mode, ['retry-image', 'retry-mixed'], true) ? [1] : []); + $expectedAlbums = $mode === 'retry-finished' ? [] : (in_array($mode, ['retry-fields', 'retry-repeat'], true) ? [30] : ($mode === 'retry-mixed' ? [10, 30] : [10])); + expect(Images::$updated === $expectedImages && Media::$calls === count($expectedImages) && Images::$synced === $expectedAlbums, '只能处理指定失败图片及字段失败图集'); + foreach ($db->reads as $sql) { expect(strpos($sql, 'WHERE (id = ') !== false, '重试不得再次扫描全库图片或字段'); } + $persisted = State::read($root . '/data/job.json'); + unset($json['ok'], $json['msg']); + expect(State::response($persisted) === $json, '重试响应必须与持久化检查点一致'); + if ($mode === 'retry-finished') { expect(file_get_contents($root . '/data/job.json') === $beforeState && $db->operations === [], '重试首批完成且丢失响应后不能再次执行'); } + } elseif ($mode === 'resync-failure') { + expect($json['finished'] === true && $json['failed'] === 1 && Media::$calls === 0 && Images::$synced === [10], '字段失败独立于图片编码'); + expect($json['failures'][0]['task'] === 'resync' && $json['failures'][0]['id'] === 10 && $json['failures'][0]['code'] === 'field_sync', '字段失败必须保留图集身份及安全原因'); + } elseif ($mode === 'lease-conflict') { expect($json['finished'] === true && strpos($json['msg'], '另一个维护任务') !== false, '同一管理员不同任务的有效租约必须拒绝'); expect(file_get_contents($root . '/data/job.lock') === $beforeLock && glob($root . '/data/*') === $beforeFiles, '租约冲突不得写入检查点或修改锁'); expect(Media::$calls === 0 && Images::$updated === [] && Images::$synced === [] && $db->operations === [], '租约冲突必须在数据库读取和媒体处理前停止'); @@ -238,7 +298,7 @@ function removeFixture(string $dir): void { expect($json['finished'] === false && $json['total'] === 5 && $json['done'] === 3 && $json['failed'] === 0, '首个请求必须在批次上限停止并保留可继续进度'); expect(Images::$updated === [1, 2, 3] && Media::$calls === 3, '首批必须恰好处理三条图片记录'); expect(Images::$synced === [10], '同一图集的多图每批只同步一次聚合字段'); - expect(State::read($root . '/data/job.json') === $json, '未完成的持久化检查点必须与响应一致'); + expect(State::response(State::read($root . '/data/job.json')) === $json, '未完成的持久化检查点必须与响应一致'); expect(State::read(State::listFile($root . '/data', $json)) === [1, 2, 3, 4, 5], '未完成任务必须保留完整稳定快照'); $lock = State::read($root . '/data/job.lock'); expect($lock['uid'] === 7 && $lock['job'] === 'rebuild', '未完成任务必须保留用户及任务锁'); @@ -246,12 +306,19 @@ function removeFixture(string $dir): void { expect(($json['finished'] ?? false) === true && !isset($json['ok']), '维护必须返回终态进度而非错误响应'); $failed = in_array($mode, ['rebuild-sync-failure', 'rebuild-missing-original', 'rebuild-conversion-failure'], true) ? 1 : 0; expect($json['failed'] === $failed, '失败计数必须在响应中可见'); + expect($json['failure_total'] === $failed && count($json['failures']) === $failed, '新任务每个失败必须有可恢复的明细'); + if ($failed) { + $failure = $json['failures'][0]; + expect($failure['cid'] === 10 && !empty($failure['reason']), '失败必须包含相册身份和安全原因'); + expect($failure['task'] === ($mode === 'rebuild-sync-failure' ? 'resync' : 'rebuild'), '字段失败只能计划字段重试'); + expect($failure['id'] === ($mode === 'rebuild-sync-failure' ? 10 : 1), '失败项必须指向真实图片或相册身份'); + } $expectedIds = in_array($mode, ['rebuild-resume', 'rebuild-start-reuse'], true) ? [3] : ($mode === 'rebuild-restart' ? [1,3,4] : (in_array($mode, ['rebuild-no-original', 'rebuild-missing-original', 'rebuild-conversion-failure'], true) ? [] : [1])); expect(Images::$updated === $expectedIds, '重建必须使用正确的快照与实时记录'); expect($json['total'] === ($mode === 'rebuild-no-original' ? 0 : (in_array($mode, ['rebuild-restart', 'rebuild-resume', 'rebuild-start-reuse'], true) ? 3 : 1)), '快照总数必须准确'); expect($json['done'] === $json['total'], '终态偏移必须等于总数'); expect(!is_file($root . '/data/job.lock'), '完成维护后必须释放任务锁'); - expect(State::read($root . '/data/job.json') === $json, '持久化状态必须与响应一致'); + expect(State::response(State::read($root . '/data/job.json')) === $json, '持久化状态必须与响应一致'); expect(!is_file(State::listFile($root . '/data', $json)), '已完成的快照必须清理'); if (in_array($mode, ['rebuild-resume', 'rebuild-start-reuse'], true)) { expect($json['job_id'] === $oldId, '未完成任务必须复用身份与偏移'); } if ($mode === 'rebuild-restart') { expect($json['job_id'] !== $oldId, '重新开始必须生成新身份并包含新增图片'); } diff --git a/tests/maintenance-state.php b/tests/maintenance-state.php index 0ce97eb..c668beb 100644 --- a/tests/maintenance-state.php +++ b/tests/maintenance-state.php @@ -41,6 +41,58 @@ function rejected(callable $fn, string $message): void { $thrown = false; try { State::write($dir . '/job.json', ['job' => 'rebuild', 'total' => 0, 'done' => 0, 'finished' => true]); [$fresh, $list] = State::open($dir, 'rebuild', 'start', '', $collect); verify($list === [11, 12, 13, 14], '旧版本完成状态不得复用旧清单'); + // 原子终态保留全部失败原因,分页输出,重试只使用失败身份。 + $fresh['done'] = $fresh['total']; $fresh['finished'] = true; + for ($id = 1; $id <= 123; $id++) { State::failure($fresh, 'rebuild', $id, 10, 'conversion'); } + State::failure($fresh, 'resync', 10, 10, 'field_sync'); + State::failure($fresh, 'resync', 10, 10, 'field_sync'); + verify($fresh['failed'] === 124, '同一图集的跨批次同步失败只能列出一次'); + State::save($dir, $fresh); + $saved = State::read($dir . '/job.json'); + verify($saved === $fresh && count($saved['failures']) === 124, '刷新必须能读回完整失败明细'); + $first = State::response($saved); $second = State::response($saved, 50); $last = State::response($saved, 100); + verify(count($first['failures']) === 50 && $first['failure_next'] === 50 && $first['failure_total'] === 124, '首屏输出必须有界并给出总数和下一页'); + verify(count($second['failures']) === 50 && count($last['failures']) === 24 && $last['failure_next'] === null, '最后一条失败也必须可以翻页查看'); + verify(count(array_unique(array_column(array_merge($first['failures'], $second['failures'], $last['failures']), 'id'))) === 123, '翻页不得遗漏任何图片身份'); + $before = file_get_contents($dir . '/job.json'); + $neverCollect = static function () { throw new RuntimeException('定向重试不得扫描全库'); }; + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'retry', '', $neverCollect); }, '空重试身份必须拒绝'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'retry', str_repeat('c', 32), $neverCollect); }, '陈旧重试身份必须拒绝'); + verify(file_get_contents($dir . '/job.json') === $before, '拒绝重试不能改写原状态'); + [$retry, $retryList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($retry['job_id'] !== $fresh['job_id'] && $retry['retry_of'] === $fresh['job_id'] && $retry['total'] === 124, '失败重试必须新建独立任务并记住原身份'); + verify($retryList[0] === ['task' => 'rebuild', 'id' => 1, 'cid' => 10] && $retryList[123] === ['task' => 'resync', 'id' => 10, 'cid' => 10], '编码与字段失败必须分开计划'); + $retry['done'] = 3; State::save($dir, $retry); + [$again, $againList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($again === $retry && $againList === $retryList, '丢失开始重试响应后仍继续同一快照与偏移'); + $before = file_get_contents($dir . '/job.json'); + rejected(static function () use ($dir, $retry, $neverCollect) { State::open($dir, 'rebuild', 'retry', $retry['job_id'], $neverCollect); }, '未完成任务不可再开启失败重试'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'cleanup', 'start', '', $neverCollect); }, '租约过期也不能覆盖异类未完成任务'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'resume', '', $neverCollect); }, '有身份的任务不能空身份续跑'); + verify(file_get_contents($dir . '/job.json') === $before, '拒绝覆盖不能改变未完成快照'); + $retry['done'] = $retry['total']; $retry['finished'] = true; + State::failure($retry, 'resync', 10, 10, 'field_sync'); + State::clearFailure($retry, 'resync', 10); + verify($retry['failed'] === 0 && $retry['failures'] === [], '后续批次成功同步必须消除已恢复的字段失败'); + State::save($dir, $retry); + [$again, $againList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($again === $retry && $againList === [], '首批重试已完成但响应丢失时不得再次新开'); + rejected(static function () use ($dir, $retry, $neverCollect) { State::open($dir, 'rebuild', 'retry', $retry['job_id'], $neverCollect); }, '没有失败项时拒绝空重试'); + [$visible] = State::open($dir, 'rebuild', 'errors', $retry['job_id'], $neverCollect); + verify($visible === $retry, '查看失败明细不触发候选扫描或处理'); + $legacyFailures = State::response(['failed' => 2, 'finished' => true]); + verify($legacyFailures['unlisted_failed'] === 2 && $legacyFailures['failure_total'] === 0, '旧版没有明细的失败必须明确告知,不能静默遗漏'); + $mixed = $retry; + $mixed['failed'] = 2; $mixed['failures'] = []; unset($mixed['unlisted_failed']); + State::failure($mixed, 'resync', 10, 10, 'field_sync'); + State::save($dir, $mixed); + [$child, $childList] = State::open($dir, 'rebuild', 'retry', $mixed['job_id'], $neverCollect); + verify($child['failed'] === 2 && $child['unlisted_failed'] === 2 && count($childList) === 1, '定向重试必须继承旧任务无法重试的失败数'); + State::clearFailure($child, 'resync', 10); + $child['finished'] = true; $child['done'] = 1; State::save($dir, $child); + verify(State::response($child)['unlisted_failed'] === 2 && $child['failed'] === 2, '有明细项恢复后不能掩盖旧失败'); + [$full] = State::open($dir, 'rebuild', 'start', '', $collect); + verify($full['failed'] === 0, '只有明确全量新任务清空旧失败基线'); // rename 到目录必失败,不能以成功返回。 mkdir($dir . '/cannot-replace'); rejected(static function () use ($dir) { State::write($dir . '/cannot-replace', ['done' => 1]); }, 'rename失败必须可见'); diff --git a/tests/media-version.php b/tests/media-version.php new file mode 100644 index 0000000..0358cf1 --- /dev/null +++ b/tests/media-version.php @@ -0,0 +1,26 @@ + '相机', '_infinity_media_version' => $token, 'gps' => ['lat' => 1]]; +$cases = [ + '/full/a.webp' => '/full/a.webp?itv=' . $token, + '/full/a.webp?x=hello%20world#zoom' => '/full/a.webp?x=hello%20world&itv=' . $token . '#zoom', + '/full/a.webp?itv=old&x=1&itv=duplicate' => '/full/a.webp?x=1&itv=' . $token, + '/full/a.webp?%69tv=old' => '/full/a.webp?itv=' . $token, + '' => '', +]; +foreach ($cases as $url => $expected) { + if (ImageRepository::versionUrl($url, $meta) !== $expected || pp_gallery_version_url($url, $meta) !== $expected) { throw new RuntimeException('前后台版本URL不一致'); } + if (ImageRepository::versionUrl($expected, $meta) !== $expected) { throw new RuntimeException('同版本重复展示不应累加参数'); } +} +foreach ([null, [], 'javascript:bad', str_repeat('a', 31)] as $invalid) { + if (pp_gallery_version_url('/a.webp', ['_infinity_media_version' => $invalid]) !== '/a.webp') { throw new RuntimeException('无效版本需忽略'); } +} +$row = ['id' => 7, 'full' => '/full/a.webp', 'thumb' => '/thumb/a.webp', 'exif' => json_encode($meta), 'variants' => ['webp' => ['/full/a.webp', '/mid/a.webp'], 'avif' => ['/avif/a.avif']]]; +$photo = pp_gallery_photo($row, 1, 100); +if ($photo['id'] !== 'p-7' || $photo['exif'] !== ['make' => '相机'] || strpos($photo['preview'], $token) === false || strpos($photo['variants']['avif'][0], $token) === false) { throw new RuntimeException('公开展示需保留ID、过滤私有EXIF及更新所有派生图'); } +$legacy = pp_gallery_photo(['full' => '/legacy.webp', 'exif' => $meta], 1, 100, true); +if ($legacy['url'] !== '/legacy.webp') { throw new RuntimeException('不得擅改旧field-only照片链接'); } +echo "图片缓存版本:前后台一致、参数幂等、元数据保护与旧图链接检查通过\n"; diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs index 64d567b..bb356b9 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.2', () => guardVersions('@version 1.14.2', "const VERSION = '1.14.2'", '{"tag_name":"1.14.2"}')); +test('允许唯一版本 1.15.0', () => guardVersions('@version 1.15.0', "const VERSION = '1.15.0'", '{"tag_name":"1.15.0"}')); for (const index of [0, 1, 2]) test(`拒绝版本字段 ${index} 不一致或未来版本`, () => { - 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'); + const values = ['@version 1.15.0', "const VERSION = '1.15.0'", '{"tag_name":"1.15.0"}']; + values[index] = values[index].replace('1.15.0', '1.15.1'); assert.throws(() => guardVersions(...values)); }); const digests = Object.fromEntries(ASSETS.map(name => [name, 'f'.repeat(64)])); -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]}` })) }; } +function complete() { return { tag_name: 'v1.15.0', 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.3' }], ['缺资产', sha, { ...complete(), assets: complete().assets.slice(1) }], + ['错误 tag', sha, { ...complete(), tag_name: 'v1.15.1' }], ['缺资产', 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/tests/repository.php b/tests/repository.php index 180064a..4c453e7 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -13,6 +13,7 @@ public function from($table): self { $this->table = $table; return $this; } public function rows($values): self { $this->values = $values; return $this; } public function where($clause, ...$values): self { $this->conditions[$clause] = $values; return $this; } public function order(...$args): self { return $this; } + public function limit($limit): self { return $this; } public function __toString(): string { return 'fixture:' . base64_encode(serialize($this)); } public function prepare(string $sql): string { return $sql; } } @@ -31,7 +32,11 @@ public function select(...$args): Query { return new Query('select'); } public function insert($table): Query { return (new Query('insert'))->from($table); } public function delete($table): Query { return (new Query('delete'))->from($table); } public function update($table): Query { return (new Query('update'))->from($table); } - public function fetchAll($query): array { return $query->table === 'test_fields' ? $this->legacyFields : $this->images; } + public function fetchAll($query): array { + $rows = $query->table === 'test_fields' ? $this->legacyFields : $this->images; + if (isset($query->conditions['id = ?'])) { $id = $query->conditions['id = ?'][0]; $rows = array_values(array_filter($rows, static function ($row) use ($id) { return $row['id'] === $id; })); } + return $rows; + } public function query($query, $op = null) { if (is_string($query) && strpos($query, 'fixture:') === 0) { if ($op !== self::WRITE) { throw new \RuntimeException('Locked reads must use writer'); } diff --git a/tests/typecho-integration.php b/tests/typecho-integration.php index ee7d9d8..6bf58b6 100644 --- a/tests/typecho-integration.php +++ b/tests/typecho-integration.php @@ -578,6 +578,29 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void check(AdminRepository::field($cid, 'img') === '' && AdminRepository::field($cid, 'photo_ids') === '', '显式清空删除最后一张图的元数据'); check(AdminRepository::album($cid, 7, false) !== null, '清空后的图集保留识别标记'); + // 重建改变公开资源版本而不改数据库路径;重复同秒重建也产生新版本。 + $versionCid = post([]); + $versionPhoto = photo($versionCid); + $rawBefore = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $versionPhoto), true); + ImageRepository::updateVariants($versionPhoto, ['width' => 1200, 'height' => 600]); + ImageRepository::syncPostFields($versionCid); + $rawAfter = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $versionPhoto), true); + $versionExif = json_decode($rawAfter['exif'], true); + check($rawAfter['full'] === $rawBefore['full'] && $rawAfter['thumb'] === $rawBefore['thumb'], '重建版本不改变物理路径'); + check($versionExif['make'] === 'Camera' && isset($versionExif['gps']), '重建版本保留私有原始EXIF'); + $firstUrl = AdminRepository::field($versionCid, 'img', true); + check(strpos($firstUrl, '?itv=' . $versionExif['_infinity_media_version']) !== false, '公开聚合图片URL带重建版本'); + $publicExif = json_decode(AdminRepository::field($versionCid, 'exif', true), true); + check(!isset($publicExif[0]['gps']) && !isset($publicExif[0]['_infinity_media_version']), '公开EXIF不包含GPS或内部版本键'); + ImageRepository::updateVariants($versionPhoto, ['width' => 1200, 'height' => 600]); + ImageRepository::syncPostFields($versionCid); + check(AdminRepository::field($versionCid, 'img', true) !== $firstUrl, '同秒再次重建也更新缓存标识'); + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/HealthDiagnostics.php'; + $healthRowsBefore = fields($versionCid); + $health = \TypechoPlugin\InfinityTime\Lib\HealthDiagnostics::collect($db, $tmp, []); + $health = array_column($health, null, 'name'); + check($health['数据库事务']['status'] === ($dialect === 'Mysql' ? 'ok' : 'manual'), '真实适配器健康检查不虚报表引擎'); + check(fields($versionCid) === $healthRowsBefore, '健康检查不改动已存字段'); // 真实旧表迁移:缺少元数据及重试列时,补齐列并保留已有记录。 Database::query('DROP TABLE ' . table('infinitytime_images'), Db::WRITE); $pk = $dialect === 'Mysql' ? 'id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY' diff --git a/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php b/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php new file mode 100644 index 0000000..0735633 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php @@ -0,0 +1,46 @@ + $name, 'status' => $status, 'detail' => $detail]; + }; + $directory = is_dir($uploadRoot); + $add('上传目录', $directory && is_writable($uploadRoot) ? 'ok' : 'warning', + $directory && is_writable($uploadRoot) ? '目录存在,PHP 报告可写;未实际写入测试文件。' : '目录不存在或 PHP 报告不可写,请由管理员检查上传目录。'); + $free = $directory && function_exists('disk_free_space') ? @disk_free_space($uploadRoot) : false; + $add('可用磁盘', $free === false ? 'manual' : ($free < 104857600 ? 'warning' : 'ok'), + $free === false ? '无法读取空间,请在主机面板核对。' : '当前可用约 ' . number_format($free / 1048576, 0) . ' MiB;转换时仍需为原图、中间文件和备份预留空间。'); + $gd = function_exists('imagewebp') && function_exists('imagecreatefromjpeg'); + $add('基础图片编码', $gd ? 'ok' : 'warning', $gd ? 'GD JPEG/WebP 函数可用;这不是编码样本测试。' : 'GD JPEG/WebP 函数不完整,请检查 PHP 扩展。'); + $advanced = !empty($tools['imagick']) || !empty($tools['magick']) || !empty($tools['convert']) || !empty($tools['heif']); + $add('HEIC/AVIF 工具', $advanced ? 'manual' : 'warning', $advanced ? '发现候选工具;实际格式支持取决于编译选项,升级后请用样本试传。' : '未发现候选工具,HEIC/AVIF 输入可能无法转换。'); + $adapter = strtolower((string)$db->getAdapterName()); + if (strpos($adapter, 'mysql') !== false) { + try { + $all = true; + foreach (['contents', 'fields', 'infinitytime_images'] as $suffix) { + $query = $db->select(); + $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($db->getPrefix() . $suffix)); + $row = $db->fetchRow(Database::query($sql)); + if (strcasecmp((string)($row['Engine'] ?? ''), 'InnoDB') !== 0) { $all = false; } + } + $add('数据库事务', $all ? 'ok' : 'warning', $all ? '相关三张表均为 InnoDB;未执行写入或回滚演练。' : '相关表缺失或不是 InnoDB,原子编辑会拒绝;请先备份,再由管理员处理。'); + } catch (\Throwable $e) { + $add('数据库事务', 'warning', '无法读取表引擎信息;请检查数据库连接和只读诊断权限。'); + } + } else { + $known = strpos($adapter, 'sqlite') !== false || strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false; + $add('数据库事务', $known ? 'manual' : 'warning', $known ? '适配器支持事务;本页未执行写入、锁竞争或真实恢复测试。' : '未识别数据库适配器,请先在隔离副本验证兼容性。'); + } + $add('上传限制', 'manual', 'PHP upload_max_filesize=' . (string)ini_get('upload_max_filesize') . ',post_max_size=' . (string)ini_get('post_max_size') . ',memory_limit=' . (string)ini_get('memory_limit') . '。代理限制和图片解码内存另需核对。'); + $add('私有原图保护', 'manual', '本页不能证明 Nginx/Apache/CDN 已禁止原图访问。请使用专用无敏感测试图片,核对 original 目录 URL 不能公开访问;不要用真实私密照片测试。'); + $add('备份与恢复', 'manual', '同时保存一致的数据库、上传目录和受保护的站点配置;在隔离环境按下方步骤恢复验证,备份文件存在不等于恢复成功。'); + return $checks; + } +} diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 0e0c55b..91e6b26 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -381,12 +381,33 @@ public static function sortImages(int $cid, array $rowIds): void }, true); } + /** 只改变公开 URL,数据库文件路径保持不变,删除和重建仍定位原文件。 */ + public static function versionUrl(string $url, array $exif): string + { + $version = $exif['_infinity_media_version'] ?? ''; + if ($url === '' || !is_string($version) || !preg_match('/^[a-f0-9]{32}$/D', $version)) { return $url; } + $fragment = explode('#', $url, 2); + $parts = explode('?', $fragment[0], 2); + $query = array_filter(explode('&', $parts[1] ?? ''), static function ($part) { + return $part !== '' && rawurldecode(explode('=', $part, 2)[0]) !== 'itv'; + }); + $query[] = 'itv=' . $version; + return $parts[0] . '?' . implode('&', $query) . (isset($fragment[1]) ? '#' . $fragment[1] : ''); + } + /** 重建后同步实际输出尺寸、大小与响应式变体路径。 */ public static function updateVariants(int $rowId, array $result): void { self::lockMedia(); $db = \Typecho\Db::get(); + $rows = self::fetchRows($db, $db->select('exif')->from(self::table())->where('id = ?', $rowId)->limit(1)); + $row = $rows[0] ?? null; + if (!$row) { throw new \RuntimeException('图片记录已变化,请刷新后重试'); } + $exif = is_array($row['exif'] ?? null) ? $row['exif'] : (json_decode((string)($row['exif'] ?? '{}'), true) ?: []); + if (!is_array($exif)) { $exif = []; } + $exif['_infinity_media_version'] = bin2hex(random_bytes(16)); $values = [ + 'exif' => json_encode($exif, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR), 'mid' => !empty($result['mid']) ? self::toWeb((string)$result['mid']) : null, 'avif' => !empty($result['avif']) ? self::toWeb((string)$result['avif']) : null, 'mid_avif' => !empty($result['mid_avif']) ? self::toWeb((string)$result['mid_avif']) : null, @@ -506,7 +527,7 @@ private static function writePostFields(int $cid, bool $clearEmpty, callable $wr $query = $db->select()->from(self::table())->where('cid = ?', $cid) ->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC); $rows = $db->fetchAll($write($query)); - foreach ($rows as &$row) { $row['exif'] = json_decode($row['exif'] ?? '{}', true); } + foreach ($rows as &$row) { $parsed = json_decode($row['exif'] ?? '{}', true); $row['exif'] = is_array($parsed) ? $parsed : []; } unset($row); // 历史文章可能仅有自定义字段。无图片行时不覆盖,删除最后一张图则显式清空。 if (!$rows && !$clearEmpty) { @@ -526,8 +547,8 @@ private static function writePostFields(int $cid, bool $clearEmpty, callable $wr foreach ($rows as $r) { $photoIds[] = (int)($r['id'] ?? 0); $months[] = self::photoMonth(is_array($r['exif'] ?? null) ? $r['exif'] : [], (int)($r['created'] ?? 0)); - $images[] = (string)($r['full'] ?? ''); - $thumbs[] = (string)($r['thumb'] ?? ''); + $images[] = self::versionUrl((string)($r['full'] ?? ''), is_array($r['exif'] ?? null) ? $r['exif'] : []); + $thumbs[] = self::versionUrl((string)($r['thumb'] ?? ''), is_array($r['exif'] ?? null) ? $r['exif'] : []); $addresses[] = (string)($r['address'] ?? ''); $titles[] = (string)($r['title'] ?? ''); $descs[] = (string)($r['desc'] ?? ''); @@ -541,12 +562,12 @@ private static function writePostFields(int $cid, bool $clearEmpty, callable $wr // 响应式变体:webp=[full, mid],avif=[full, mid](缺失的自动过滤) $variants[] = [ 'w' => $w, - 'webp' => array_values(array_filter([(string)($r['full'] ?? ''), (string)($r['mid'] ?? '')])), - 'avif' => array_values(array_filter([(string)($r['avif'] ?? ''), (string)($r['mid_avif'] ?? '')])), + 'webp' => array_values(array_filter(array_map(static function ($url) use ($r) { return self::versionUrl($url, $r['exif']); }, [(string)($r['full'] ?? ''), (string)($r['mid'] ?? '')]))), + 'avif' => array_values(array_filter(array_map(static function ($url) use ($r) { return self::versionUrl($url, $r['exif']); }, [(string)($r['avif'] ?? ''), (string)($r['mid_avif'] ?? '')]))), ]; // 重建文章里的 exif 字段时同样剔除 gps(历史数据也据此清掉) $e = is_array($r['exif'] ?? null) ? $r['exif'] : []; - unset($e['gps']); + unset($e['gps'], $e['_infinity_media_version']); $exifs[] = $e; } foreach (['img' => $images, 'thumb' => $thumbs] as $name => $paths) { diff --git a/usr/plugins/InfinityTime/Lib/MaintenanceState.php b/usr/plugins/InfinityTime/Lib/MaintenanceState.php index db355f5..3ea8910 100644 --- a/usr/plugins/InfinityTime/Lib/MaintenanceState.php +++ b/usr/plugins/InfinityTime/Lib/MaintenanceState.php @@ -1,48 +1,84 @@ $failure['task'], 'id' => (int)$failure['id'], 'cid' => (int)($failure['cid'] ?? 0)]; + } + if (!$list) { throw new MaintenanceException('没有可单独重试的失败项'); } + $unlisted = max(0, (int)($state['failed'] ?? 0) - count($state['failures'] ?? [])); + $state = self::newState($job, count($list), $id); + $state['unlisted_failed'] = $unlisted; + $state['failed'] = $unlisted; + self::write(self::listFile($dir, $state), $list); + self::write($file, $state); + return [$state, $list]; + } + // 任务身份比短期租约更持久,不能以另一个种类覆盖未完成快照。 + if ($state && !$same && empty($state['finished'])) { + throw new MaintenanceException('另一个维护任务尚未完成,请先继续该任务'); } - if ($mode === 'poll' && $id === '') { throw new \RuntimeException('缺少维护任务标识,请刷新页面'); } if ($same && (empty($state['finished']) || $mode !== 'start')) { if (!empty($state['finished'])) { return [$state, []]; } $oldId = (string)($state['job_id'] ?? ''); + if ($oldId !== '') { self::listFile($dir, $state); } $listFile = $dir . '/' . $job . ($oldId !== '' ? '_' . $oldId : '') . '_list.json'; - if (!is_file($listFile)) { throw new \RuntimeException('维护清单缺失,不能安全继续,请恢复任务数据'); } + if (!is_file($listFile)) { throw new MaintenanceException('维护清单缺失,不能安全继续,请恢复任务数据'); } $list = self::read($listFile); // 升级前的未完成任务沿用旧清单与偏移,再赋予身份以拒绝迟到请求。 if ($oldId === '') { @@ -52,21 +88,70 @@ public static function open(string $dir, string $job, string $mode, string $id, } return [$state, $list]; } - if ($mode !== 'start') { throw new \RuntimeException('没有可继续的维护任务'); } + if ($mode !== 'start') { throw new MaintenanceException('没有可继续的维护任务'); } $list = array_values($collect()); - $state = ['job' => $job, 'job_id' => bin2hex(random_bytes(16)), 'total' => count($list), - 'done' => 0, 'current' => '', 'failed' => 0, 'finished' => false]; + $state = self::newState($job, count($list)); self::write(self::listFile($dir, $state), $list); self::write($file, $state); return [$state, $list]; } + private static function newState(string $job, int $total, string $retryOf = ''): array + { + return ['job' => $job, 'job_id' => bin2hex(random_bytes(16)), 'retry_of' => $retryOf, 'total' => $total, + 'done' => 0, 'current' => '', 'failed' => 0, 'failures' => [], 'finished' => false]; + } + + /** 原因来自固定枚举;不将编码器、数据库异常或服务器路径送到客户端。 */ + public static function failure(array &$state, string $task, int $id, int $cid, string $code): void + { + $reasons = ['missing_original' => '原图缺失,恢复原图后重试', + 'conversion' => '图片编码失败,请检查原图与转换工具', + 'image_record' => '图片记录保存失败,请检查数据库后重试', + 'field_sync' => '图集字段同步失败;重试时只同步字段']; + if (!in_array($task, ['rebuild', 'resync'], true) || $id <= 0 || !isset($reasons[$code])) { + throw new MaintenanceException('无效的维护失败记录'); + } + self::prepareFailures($state); + $state['failures'][$task . ':' . $id] = ['task' => $task, 'id' => $id, 'cid' => $cid, 'code' => $code, 'reason' => $reasons[$code]]; + $state['failed'] = count($state['failures']) + $state['unlisted_failed']; + } + + public static function clearFailure(array &$state, string $task, int $id): void + { + self::prepareFailures($state); + unset($state['failures'][$task . ':' . $id]); + $state['failed'] = count($state['failures']) + $state['unlisted_failed']; + } + + private static function prepareFailures(array &$state): void + { + if (!isset($state['failures'])) { $state['failures'] = []; } + if (!isset($state['unlisted_failed'])) { $state['unlisted_failed'] = max(0, (int)($state['failed'] ?? 0) - count($state['failures'])); } + } + + /** 每个响应最多 50 条,所有记录均可用同一个任务身份翻页查看。 */ + public static function response(array $state, int $offset = 0): array + { + $failures = array_values($state['failures'] ?? []); + $total = count($failures); + $offset = max(0, min($offset, max(0, $total - 1))); + $offset = intdiv($offset, self::FAILURE_PAGE_SIZE) * self::FAILURE_PAGE_SIZE; + $out = array_intersect_key($state, array_flip(['job', 'job_id', 'retry_of', 'total', 'done', 'current', 'failed', 'finished'])); + $out['failures'] = array_slice($failures, $offset, self::FAILURE_PAGE_SIZE); + $out['failure_total'] = $total; + $out['failure_offset'] = $offset; + $out['failure_next'] = $offset + self::FAILURE_PAGE_SIZE < $total ? $offset + self::FAILURE_PAGE_SIZE : null; + $out['unlisted_failed'] = max(0, (int)($state['failed'] ?? 0) - $total); + return $out; + } + public static function listFile(string $dir, array $state): string { $job = (string)($state['job'] ?? ''); $id = (string)($state['job_id'] ?? ''); if (!in_array($job, ['rebuild', 'cleanup', 'resync'], true) || !preg_match('/^[a-f0-9]{32}$/D', $id)) { - throw new \RuntimeException('无效的维护任务状态'); + throw new MaintenanceException('无效的维护任务状态'); } return $dir . '/' . $job . '_' . $id . '_list.json'; } @@ -74,7 +159,7 @@ public static function listFile(string $dir, array $state): string public static function save(string $dir, array $state): void { self::write($dir . '/job.json', $state); - // 先保存终态;即使清单清理失败,后续新任务仍会生成全新快照。 + // 失败项留在原子检查点中,完成后可重试;无需保留全库候选清单。 if (!empty($state['finished'])) { @unlink(self::listFile($dir, $state)); } } } diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index 988b7b2..980d043 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -4,7 +4,7 @@ * * @package InfinityTime * @author InfinityTime - * @version 1.14.2 + * @version 1.15.0 * @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.2'; + public const VERSION = '1.15.0'; public const MENU_NAME = 'InfinityTime'; // 统一默认(“恢复默认/写入”)配置,避免各处写死不同数值 public const DEFAULT_QUALITY = 76; // 缩略图质量 diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js index 7d6fa0e..e01333f 100644 --- a/usr/plugins/InfinityTime/assets/admin.js +++ b/usr/plugins/InfinityTime/assets/admin.js @@ -580,25 +580,79 @@ } /* ---------- 维护任务 ---------- */ - var runningJobs = {}; - function runJob(job, resume) { - if (runningJobs[job]) return; - runningJobs[job] = true; + var runningJob = ''; + var pendingJobs = {}; + var loadingFailures = false; + function maintenanceBusy(busy) { + $$('[data-run]').forEach(function (button) { button.disabled = busy; }); + var retry = $('#pp-retry-failures'); + if (retry) retry.disabled = busy || !JOB || !JOB.finished || !JOB.failure_total; + var prev = $('#pp-failure-prev'), next = $('#pp-failure-next'); + if (prev) prev.disabled = busy || loadingFailures || !JOB || !(JOB.failure_offset > 0); + if (next) next.disabled = busy || loadingFailures || !JOB || JOB.failure_next == null; + } + function renderFailures(state) { + var panel = $('#pp-maintenance-failures'); + var list = $('#pp-failure-list'); + var summary = $('#pp-failure-summary'); + if (!panel || !list || !summary) return; + var failures = state && state.failures || []; + var total = state && state.failure_total || 0; + var unlisted = state && state.unlisted_failed || 0; + panel.hidden = !(total || unlisted); + list.textContent = ''; + list.start = (state && state.failure_offset || 0) + 1; + failures.forEach(function (failure) { + var row = document.createElement('li'); + var identity = failure.task === 'resync' ? '相册 ID ' + failure.id : '图片 ID ' + failure.id + (failure.cid > 0 ? ' / 相册 ID ' + failure.cid : ''); + row.textContent = identity + ':' + failure.reason; + list.appendChild(row); + }); + summary.textContent = total ? ('共 ' + total + ' 项失败,显示 ' + (list.start) + '–' + ((state.failure_offset || 0) + failures.length) + ' 项。') : ''; + if (unlisted) summary.textContent += ' 另有 ' + unlisted + ' 项来自旧版任务,未保存明细,无法单独重试。'; + var retry = $('#pp-retry-failures'); + if (retry) retry.textContent = unlisted ? '重试有明细的失败项' : '仅重试失败项'; + maintenanceBusy(!!runningJob); + } + function failurePage(offset) { + if (runningJob || loadingFailures || !JOB || !JOB.job_id) return; + loadingFailures = true; + maintenanceBusy(false); + var id = JOB.job_id; + post('maintenance', { job: JOB.job, mode: 'errors', job_id: id, offset: offset }) + .then(function (data) { + if (!data || data.ok === false) throw new Error((data && data.msg) || '无法读取失败清单'); + if (JOB && JOB.job_id === id && data.job_id === id) { JOB = data; renderFailures(JOB); } + }) + .catch(function (error) { + if (JOB && JOB.job_id === id) { var summary = $('#pp-failure-summary'); if (summary) summary.textContent = error.message; } + }) + .then(function () { loadingFailures = false; maintenanceBusy(!!runningJob); }); + } + function runJob(job, requestedMode) { + if (runningJob) return; + runningJob = job; var bar = $('#pp-bar-' + job); var msg = $('#pp-msg-' + job); - var btn = document.querySelector('[data-run="' + job + '"]'); - if (btn) btn.disabled = true; - if (bar) bar.style.width = '0%'; // 每次开始先归零,避免上一次的 100% 残留 - if (msg) msg.textContent = '准备中…'; - var mode = resume ? 'resume' : 'start'; - var jobId = resume && JOB ? (JOB.job_id || '') : ''; + var mode = requestedMode === true ? 'resume' : (requestedMode || 'start'); + var jobId = mode !== 'start' && JOB ? (JOB.job_id || '') : ''; + // 请求响应丢失后沿用原始身份,尤其不能把完成的定向重试当作一次全库 start。 + if (pendingJobs[job]) { mode = pendingJobs[job].mode; jobId = pendingJobs[job].id; } + else if (mode === 'start' && JOB && JOB.job === job && !JOB.finished) { mode = 'resume'; jobId = JOB.job_id || ''; } + maintenanceBusy(true); + if (bar) bar.style.width = '0%'; + if (msg) msg.textContent = mode === 'retry' ? '准备重试失败项…' : '准备中…'; + function stop() { runningJob = ''; maintenanceBusy(false); } function tick() { + pendingJobs[job] = { mode: mode, id: jobId }; post('maintenance', { job: job, mode: mode, job_id: jobId }) .then(function (d) { if (!d || d.ok === false) throw new Error((d && d.msg) || '出错,请重试'); - if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; if (btn) btn.disabled = false; runningJobs[job] = false; return; } + if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; stop(); return; } jobId = d.job_id || jobId; mode = 'poll'; + JOB = d; + renderFailures(d); var pct = d.total ? Math.round(d.done * 100 / d.total) : 100; if (bar) bar.style.width = pct + '%'; if (d.total > 0) { @@ -608,14 +662,13 @@ if (msg) msg.textContent = noJob[job] || '没有需要处理的项目'; } if (!d.finished) { setTimeout(tick, 300); return; } - if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 项失败,请查看日志)') : ' ✓ 完成'; - if (btn) btn.disabled = false; - runningJobs[job] = false; + if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 项失败,请查看下方清单)') : ' ✓ 完成'; + delete pendingJobs[job]; + stop(); }) .catch(function (err) { if (msg) msg.textContent = err.message || '出错,请重试'; - if (btn) btn.disabled = false; - runningJobs[job] = false; + stop(); }); } tick(); @@ -766,8 +819,17 @@ }).catch(function () { notice('网络/保存出错,请重试', 'error'); if (btn) btn.disabled = false; form.__ppSaving = false; }); }); - // 上次维护任务未完成:提示可继续 - if (JOB && JOB.job && JOB.total > 0 && !JOB.finished) { + renderFailures(JOB); + var retryFailures = $('#pp-retry-failures'); + if (retryFailures) retryFailures.addEventListener('click', function () { + if (JOB && JOB.finished && JOB.failure_total > 0) runJob(JOB.job, 'retry'); + }); + var previousFailures = $('#pp-failure-prev'), nextFailures = $('#pp-failure-next'); + if (previousFailures) previousFailures.addEventListener('click', function () { if (JOB) failurePage(Math.max(0, (JOB.failure_offset || 0) - 50)); }); + if (nextFailures) nextFailures.addEventListener('click', function () { if (JOB && JOB.failure_next != null) failurePage(JOB.failure_next); }); + + // 上次维护任务未完成:提示可继续(包括总数为零、尚未保存终态的任务)。 + if (JOB && JOB.job && !JOB.finished) { var names = { rebuild: '重建缩略图/全图', cleanup: '清理孤儿文件', resync: '重建尺寸字段' }; var tip = document.createElement('div'); tip.className = 'notice success'; diff --git a/usr/plugins/InfinityTime/health-panel.php b/usr/plugins/InfinityTime/health-panel.php new file mode 100644 index 0000000..49e9de1 --- /dev/null +++ b/usr/plugins/InfinityTime/health-panel.php @@ -0,0 +1,26 @@ +pass('administrator', true)) { return; } +require_once __DIR__ . '/Lib/HealthDiagnostics.php'; +$ppHealth = \TypechoPlugin\InfinityTime\Lib\HealthDiagnostics::collect($db, \TypechoPlugin\InfinityTime\Lib\ImageRepository::uploadRoot(), $tools); +$ppHealthLabels = ['ok' => '已检查', 'warning' => '需处理', 'manual' => '需人工核对']; +?> +
+

只读健康检查

+

刷新后台时读取当前环境,不写测试文件、不修改数据库、不探测外部地址。以下结果不代替真实图片上传与备份恢复演练。

+ +
+ 在隔离环境做一次恢复演练 +
    +
  1. 记录当前主题、插件和 Typecho 版本,安排一致备份窗口;同时备份数据库、上传目录及受保护的站点配置。备份放在网站公开目录之外。
  2. +
  3. 将备份恢复到隔离副本,使用副本专用数据库和上传目录。暂停该副本的邮件、通知、计划任务及任何外部写入,避免影响正式站点。
  4. +
  5. 核对相册与照片数量、草稿权限、排序和中文说明;抽查普通图、全景图、缩略图及原图访问保护。用无敏感样本测试上传、编辑和重建。
  6. +
  7. 记录备份时间、恢复耗时、缺失项及验证结果;确认能够恢复后再清理过期备份。这里不会自动恢复或覆盖正式数据。
  8. +
+
+
diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index e27b196..f87430f 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -307,15 +307,18 @@ function pp_exif_summary(array $exif): string $result = ['finished' => true, 'total' => 0, 'done' => 0, 'current' => '']; try { + if ($ppMaintenance && ($_POST['mode'] ?? '') === 'errors') { + [$state] = MaintenanceState::open(pp_data_file(), $job, 'errors', (string)($_POST['job_id'] ?? ''), static function () { return []; }); + pp_reply_json(true, '', MaintenanceState::response($state, max(0, (int)($_POST['offset'] ?? 0)))); + } if ($job === 'rebuild') { [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () use ($db) { $rows = AdminRepository::readAll($db->select('id')->from(ImageRepository::table())->where('original <> ?', ''), true); return array_map(static function ($row) { return (int)$row['id']; }, $rows); }); - if (!empty($state['finished'])) { pp_reply_json(true, '', $state); } + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); - $failed = (int)($state['failed'] ?? 0); $batch = 3; $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); @@ -333,56 +336,66 @@ function pp_exif_summary(array $exif): string break; } $snapshot = $list[$idx]; - $rowId = is_array($snapshot) ? (int)$snapshot[0] : (int)$snapshot; - // 快照只决定候选身份;路径与归属在每批主库重读,避免删除/改绑后重建旧记录。 - $live = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); - if (!$live || empty($live['original'])) { $idx++; continue; } - $item = [$rowId, $live['original'], $live['full'], $live['thumb'], (int)($live['cid'] ?? 0)]; - $src = ImageRepository::toAbs((string)$item[1]); - if (is_file($src)) { - $mw = $maxWidth; - $fq = $fullQuality; - $__info = @getimagesize($src); - if (is_array($__info) && ($__info[0] ?? 0) > 0 && ($__info[1] ?? 0) > 0 - && ImageRepository::isPano((int)$__info[0], (int)$__info[1])) { - $mw = $panoWidth > 0 ? (int)min((int)$__info[0], $panoWidth) : 0; // 全景:独立宽度,0=不裁剪 - $fq = $panoQuality; - } - try { - $res = MediaProcessor::process($src, ImageRepository::toAbs($item[2]), ImageRepository::toAbs($item[3]), $thumbMax, $quality, $mw, $fq); - ImageRepository::updateVariants((int)$item[0], $res); - if (!empty($item[4])) { - $syncedCids[(int)$item[4]] = true; + // 字段失败的重试只同步该图集,绝不重复图片编码。 + if (is_array($snapshot) && ($snapshot['task'] ?? '') === 'resync') { + $syncedCids[(int)$snapshot['id']] = true; + $idx++; + continue; + } + $rowId = is_array($snapshot) ? (int)($snapshot['id'] ?? $snapshot[0] ?? 0) : (int)$snapshot; + $cid = 0; + $stage = 'image_record'; + $state['current'] = '图片 ID ' . $rowId; + try { + // 快照只决定候选身份;路径与归属在每批主库重读,避免删除/改绑后重建旧记录。 + $live = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); + if (!$live) { MaintenanceState::clearFailure($state, 'rebuild', $rowId); $idx++; continue; } + $cid = (int)($live['cid'] ?? 0); + $src = ImageRepository::toAbs((string)$live['original']); + if (empty($live['original']) || !is_file($src)) { + MaintenanceState::failure($state, 'rebuild', $rowId, $cid, 'missing_original'); + $state['current'] .= '(缺原图)'; + } else { + $mw = $maxWidth; + $fq = $fullQuality; + $__info = @getimagesize($src); + if (is_array($__info) && ($__info[0] ?? 0) > 0 && ($__info[1] ?? 0) > 0 + && ImageRepository::isPano((int)$__info[0], (int)$__info[1])) { + $mw = $panoWidth > 0 ? (int)min((int)$__info[0], $panoWidth) : 0; + $fq = $panoQuality; } - } catch (\Throwable $e) { - Plugin::log('rebuild ajax: id=' . $item[0] . ' ' . $e->getMessage()); - $failed++; + $stage = 'conversion'; + $res = MediaProcessor::process($src, ImageRepository::toAbs((string)$live['full']), ImageRepository::toAbs((string)$live['thumb']), $thumbMax, $quality, $mw, $fq); + $stage = 'image_record'; + ImageRepository::updateVariants($rowId, $res); + MaintenanceState::clearFailure($state, 'rebuild', $rowId); + if ($cid > 0) { $syncedCids[$cid] = true; } } - $state['current'] = basename($src); - } else { - // 原图缺失:无法重建,计为失败(不要静默跳过) - $failed++; - $state['current'] = basename($src) . '(缺原图)'; + } catch (\Throwable $e) { + Plugin::log('rebuild ajax: id=' . $rowId . ' ' . $e->getMessage()); + MaintenanceState::failure($state, 'rebuild', $rowId, $cid, $stage); } $idx++; } foreach (array_keys($syncedCids) as $__c) { - try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) { + try { + ImageRepository::syncPostFields((int)$__c); + MaintenanceState::clearFailure($state, 'resync', (int)$__c); + } catch (\Throwable $e) { Plugin::log('rebuild sync: cid=' . $__c . ' ' . $e->getMessage()); - $failed++; + MaintenanceState::failure($state, 'resync', (int)$__c, (int)$__c, 'field_sync'); $state['current'] = 'cid ' . $__c . '(字段同步失败,可重建字段重试)'; } } $state['done'] = $idx; - $state['failed'] = $failed; $state['finished'] = $idx >= $total; MaintenanceState::save(pp_data_file(), $state); - $result = $state; + $result = MaintenanceState::response($state); } elseif ($job === 'cleanup') { [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () { return ImageRepository::orphanCandidates(); }); - if (!empty($state['finished'])) { pp_reply_json(true, '', $state); } + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); $batch = 50; @@ -394,7 +407,7 @@ function pp_exif_summary(array $exif): string $state['done'] = $idx; $state['finished'] = $idx >= count($list); MaintenanceState::save(pp_data_file(), $state); - $result = $state; + $result = MaintenanceState::response($state); } elseif ($job === 'resync') { // 重算每篇图集的聚合字段(addresses/titles/descs/panos,并补上 dims 宽高数组)。 // 用于给已发布的历史文章补齐文章字段,使首页瀑布流能拿到图片比例做占位。 @@ -410,10 +423,9 @@ function pp_exif_summary(array $exif): string sort($list); return $list; }); - if (!empty($state['finished'])) { pp_reply_json(true, '', $state); } + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); - $failed = (int)($state['failed'] ?? 0); $batch = 12; // 每次处理 12 篇,避免单次 AJAX 超时 $started = microtime(true); $budget = 20; @@ -422,24 +434,24 @@ function pp_exif_summary(array $exif): string if ((microtime(true) - $started) > $budget) { break; } - $cid = (int)($list[$idx] ?? 0); + $cid = is_array($list[$idx]) ? (int)($list[$idx]['id'] ?? 0) : (int)$list[$idx]; if ($cid > 0) { try { ImageRepository::syncPostFields($cid); + MaintenanceState::clearFailure($state, 'resync', $cid); $state['current'] = 'cid ' . $cid; } catch (\Throwable $e) { Plugin::log('resync ajax: cid=' . $cid . ' ' . $e->getMessage()); - $failed++; + MaintenanceState::failure($state, 'resync', $cid, $cid, 'field_sync'); $state['current'] = 'cid ' . $cid . '(失败)'; } } $idx++; } $state['done'] = $idx; - $state['failed'] = $failed; $state['finished'] = $idx >= $total; MaintenanceState::save(pp_data_file(), $state); - $result = $state; + $result = MaintenanceState::response($state); } elseif ($job === 'albums_html') { // 局部刷新图集列表:只返回卡片 HTML,避免为刷新列表重新渲染整个后台页面 header('Content-Type: text/html; charset=utf-8'); @@ -466,7 +478,8 @@ function pp_exif_summary(array $exif): string } catch (\Throwable $e) { Plugin::log('maintenance: ' . $e->getMessage()); - pp_reply_json(false, $e->getMessage(), ['retryable' => true], 500); + $safeMessage = $e instanceof \TypechoPlugin\InfinityTime\Lib\MaintenanceException ? $e->getMessage() : '维护请求未完成,请刷新核对任务状态并检查服务器日志'; + pp_reply_json(false, $safeMessage, ['retryable' => true], 500); } if ($ppMaintenance && !empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); } @@ -894,7 +907,7 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string ?>
- +
×
@@ -955,7 +968,7 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string } } $ppOperationKey = bin2hex(random_bytes(16)); -try { $ppJobState = pp_read_json(pp_data_file() . '/job.json'); } +try { $ppJobState = pp_is_admin() ? MaintenanceState::response(pp_read_json(pp_data_file() . '/job.json')) : []; } catch (\Throwable $e) { $ppJobState = []; $notice = $e->getMessage(); $noticeType = 'error'; } /* ---------------------------------- 视图 ---------------------------------- */ @@ -1146,6 +1159,7 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string
+

维护

@@ -1182,6 +1196,15 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string
重算所有图集的文章字段(含缩略图宽高),供首页瀑布流按比例预占位,避免图片加载时顺序跳变。升级后跑一次即可。
+
删除所有不是 InfinityTime 发布的 type=post 文章(不含本插件图集;会连同自定义字段一起删除)。
@@ -1212,12 +1235,7 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string 'panoWidth' => Plugin::DEFAULT_PANO_WIDTH, 'panoQuality' => Plugin::DEFAULT_PANO_QUALITY, ], - 'job' => [ - 'job' => (string)($ppJobState['job'] ?? ''), - 'total' => (int)($ppJobState['total'] ?? 0), - 'done' => (int)($ppJobState['done'] ?? 0), - 'finished' => !empty($ppJobState['finished']), - ], + 'job' => $ppJobState, ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>; diff --git a/usr/themes/InfinityTime/assets/css/gallery.css b/usr/themes/InfinityTime/assets/css/gallery.css index 1862568..b014e29 100644 --- a/usr/themes/InfinityTime/assets/css/gallery.css +++ b/usr/themes/InfinityTime/assets/css/gallery.css @@ -21,3 +21,7 @@ #main > #waterfall .thumb > h2 { position: static; padding: .6rem .75rem .2rem; } .gallery-card-links { position: relative; z-index: 2; display: flex; flex-wrap: wrap; gap: .25rem .65rem; padding: .25rem .75rem .6rem; font-size: .8rem; } .gallery-card-links a { display: inline-block; padding: .25rem 0; } + +.gallery-data-saver:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-top: .8rem; font-size: .85rem; } +.gallery-data-saver select { width: auto; min-height: 2.75rem; } +#gallery-data-saver-status { color: #aaa; } diff --git a/usr/themes/InfinityTime/assets/js/data-saver.js b/usr/themes/InfinityTime/assets/js/data-saver.js new file mode 100644 index 0000000..6f213d5 --- /dev/null +++ b/usr/themes/InfinityTime/assets/js/data-saver.js @@ -0,0 +1,41 @@ +/* 手机省流:可显式覆盖系统建议,存储不可用时仍保持本次选择。 */ +(function () { + 'use strict'; + var key = 'InfinityTime.dataSaver'; + var choice = 'auto'; + var control = document.getElementById('gallery-data-saver'); + var connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + function valid(value) { return value === 'on' || value === 'off' || value === 'auto'; } + try { var saved = localStorage.getItem(key); if (valid(saved)) choice = saved; } catch (e) {} + function enabled() { + return choice === 'on' || (choice === 'auto' && !!(connection && + (connection.saveData || /^(slow-2g|2g)$/.test(connection.effectiveType || '')))); + } + function notify() { + if (control) control.value = choice; + var status = document.getElementById('gallery-data-saver-status'); + if (status) status.textContent = enabled() ? '已省流:点击加载更多,暂停相邻大图预取。' : '自动加载照片,并预取相邻大图。'; + window.dispatchEvent(new CustomEvent('infinity:network-preference', { detail: { enabled: enabled() } })); + } + window.InfinityDataSaver = { + isEnabled: enabled, + allowBackground: function () { return !enabled() && !document.hidden; } + }; + if (control) { + control.value = choice; + control.parentNode.hidden = false; + control.addEventListener('change', function () { + if (!valid(control.value)) return; + choice = control.value; + try { localStorage.setItem(key, choice); } catch (e) {} + notify(); + }); + } + if (connection && connection.addEventListener) connection.addEventListener('change', notify); + window.addEventListener('storage', function (event) { + if (event.key !== key && event.key !== null) return; + choice = valid(event.newValue) ? event.newValue : 'auto'; + notify(); + }); + notify(); +}()); diff --git a/usr/themes/InfinityTime/assets/js/init.js b/usr/themes/InfinityTime/assets/js/init.js index c3d8104..76073f7 100644 --- a/usr/themes/InfinityTime/assets/js/init.js +++ b/usr/themes/InfinityTime/assets/js/init.js @@ -145,6 +145,7 @@ update(); } function loadMore(manual) { + if (!manual && (document.hidden || (window.InfinityDataSaver && !window.InfinityDataSaver.allowBackground()))) return Promise.resolve(false); // 自动滚动与主动请求共享结果;busy 不能被调用方当作分页失败。 if (activeRequest) return activeRequest.promise; if (!hasMore() || (failed && !manual)) return Promise.resolve(false); diff --git a/usr/themes/InfinityTime/assets/js/main.js b/usr/themes/InfinityTime/assets/js/main.js index 77aa60f..e86ef73 100755 --- a/usr/themes/InfinityTime/assets/js/main.js +++ b/usr/themes/InfinityTime/assets/js/main.js @@ -827,7 +827,7 @@ document.addEventListener('DOMContentLoaded', function() { // 全景查看器仍预加载原图。 var __preloaded = new Map(); function preloadUrl(u) { - if (!u) return; + if (!u || document.hidden || (window.InfinityDataSaver && !window.InfinityDataSaver.allowBackground())) return; var key = u + '|' + window.innerWidth + '|' + (window.devicePixelRatio || 1); if (__preloaded.has(key)) return; var host = document.createElement('div'); @@ -848,6 +848,21 @@ document.addEventListener('DOMContentLoaded', function() { // 也不阻止预加载失败的图片再次重试。 while (__preloaded.size > 64) __preloaded.delete(__preloaded.keys().next().value); } + function releasePreloads() { + __preloaded.forEach(function(entry) { + if (!entry || entry === true) return; + entry.image.onload = entry.image.onerror = null; + entry.host.querySelectorAll('source').forEach(function(source) { source.removeAttribute('srcset'); }); + entry.image.removeAttribute('srcset'); + entry.image.removeAttribute('src'); + }); + __preloaded.clear(); + } + window.addEventListener('pagehide', releasePreloads); + window.addEventListener('infinity:network-preference', function() { + if (window.InfinityDataSaver && window.InfinityDataSaver.isEnabled()) releasePreloads(); + }); + document.addEventListener('visibilitychange', function() { if (document.hidden) releasePreloads(); }); function preloadNeighbors(popup) { try { var img = popup && popup.querySelector('.pic img'); diff --git a/usr/themes/InfinityTime/index.php b/usr/themes/InfinityTime/index.php index 19faf44..7eba693 100755 --- a/usr/themes/InfinityTime/index.php +++ b/usr/themes/InfinityTime/index.php @@ -3,7 +3,7 @@ * 一款简约的相册主题 * @package 无限时光 * @author InfinityTime - * @version 1.14.2 + * @version 1.15.0 * @link https://github.com/InfinityTime/InfinityTime */ ?> @@ -35,7 +35,7 @@ } $__galleryGroups = pp_gallery_groups($__gallery['albums']); // 静态资源版本号(以文件 mtime 生成,改动即失效缓存,避免改后还看到旧的 CSS/JS) -$__assetVer = substr(md5((string)@filemtime(__DIR__ . '/assets/css/main.css') . (string)@filemtime(__DIR__ . '/assets/js/main.js') . (string)@filemtime(__DIR__ . '/assets/js/lightbox.js') . (string)@filemtime(__DIR__ . '/assets/js/init.js') . (string)@filemtime(__DIR__ . '/assets/js/gallery-navigation.js') . (string)@filemtime(__DIR__ . '/assets/css/gallery.css')), 0, 8); +$__assetVer = substr(md5((string)@filemtime(__DIR__ . '/assets/css/main.css') . (string)@filemtime(__DIR__ . '/assets/js/main.js') . (string)@filemtime(__DIR__ . '/assets/js/lightbox.js') . (string)@filemtime(__DIR__ . '/assets/js/init.js') . (string)@filemtime(__DIR__ . '/assets/js/data-saver.js') . (string)@filemtime(__DIR__ . '/assets/js/gallery-navigation.js') . (string)@filemtime(__DIR__ . '/assets/css/gallery.css')), 0, 8); // JSON 嵌入 HTML 属性时的安全标志:把 ' " & < > 转成 \uXXXX, // 防止用户标题/描述/文件名等含引号或尖括号时破坏属性或注入脚本(存储型 XSS)。 $__jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_HEX_TAG; @@ -106,6 +106,7 @@ 全部照片 +
@@ -177,6 +178,7 @@ InfinityTime Theme
+ diff --git a/usr/themes/InfinityTime/lib/gallery.php b/usr/themes/InfinityTime/lib/gallery.php index e1a2c0b..bacef85 100644 --- a/usr/themes/InfinityTime/lib/gallery.php +++ b/usr/themes/InfinityTime/lib/gallery.php @@ -116,6 +116,20 @@ function pp_gallery_variant($input): array return $out; } +/** 只改变公开 URL,数据库文件路径保持不变,删除和重建仍定位原文件。 */ +function pp_gallery_version_url(string $url, array $exif): string +{ + $version = $exif['_infinity_media_version'] ?? ''; + if ($url === '' || !is_string($version) || !preg_match('/^[a-f0-9]{32}$/D', $version)) { return $url; } + $fragment = explode('#', $url, 2); + $parts = explode('?', $fragment[0], 2); + $query = array_filter(explode('&', $parts[1] ?? ''), static function ($part) { + return $part !== '' && rawurldecode(explode('=', $part, 2)[0]) !== 'itv'; + }); + $query[] = 'itv=' . $version; + return $parts[0] . '?' . implode('&', $query) . (isset($fragment[1]) ? '#' . $fragment[1] : ''); +} + /** 仓库照片和仅有自定义字段的历史照片使用相同公开结构。 */ function pp_gallery_photo(array $row, int $cid, int $albumCreated, bool $legacy = false): ?array { @@ -127,13 +141,18 @@ function pp_gallery_photo(array $row, int $cid, int $albumCreated, bool $legacy $id = (int)($row['id'] ?? 0) > 0 ? 'p-' . (int)$row['id'] : 'l-' . substr(hash('sha256', $cid . "\n" . $url), 0, 20); $month = pp_gallery_month($exif, (int)($row['created'] ?? 0) ?: $albumCreated); if ($legacy && preg_match('/^[1-9]\d{3}-(0[1-9]|1[0-2])$/D', (string)($row['month'] ?? ''))) { $month = $row['month']; } + $revision = !$legacy ? pp_gallery_json_array($row['exif'] ?? []) : []; + $variants = pp_gallery_variant($row['variants'] ?? []); + foreach (['webp', 'avif'] as $format) { + $variants[$format] = array_map(static function ($value) use ($revision) { return pp_gallery_version_url($value, $revision); }, $variants[$format]); + } return [ - 'id' => $id, 'url' => $url, 'preview' => pp_gallery_media_url($row['thumb'] ?? '') ?: $url, + 'id' => $id, 'url' => pp_gallery_version_url($url, $revision), 'preview' => pp_gallery_version_url(pp_gallery_media_url($row['thumb'] ?? '') ?: $url, $revision), 'title' => pp_gallery_text($row['title'] ?? '', 300), 'description' => pp_gallery_text($row['desc'] ?? ''), 'address' => pp_gallery_text($row['address'] ?? '', 300), 'exif' => $exif, 'pano' => ($width > 0 && $height > 0) ? ($width / $height >= 1.98 && $width / $height <= 2.02) : !empty($row['pano']), 'width' => $width, 'height' => $height, 'month' => $month, - 'variants' => pp_gallery_variant($row['variants'] ?? []), + 'variants' => $variants, ]; } diff --git a/usr/themes/InfinityTime/releases.json b/usr/themes/InfinityTime/releases.json index dc628c9..f7f6e92 100644 --- a/usr/themes/InfinityTime/releases.json +++ b/usr/themes/InfinityTime/releases.json @@ -1,4 +1,4 @@ { - "tag_name": "1.14.2" + "tag_name": "1.15.0" } diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index 951c541..d04446e 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -1,5 +1,14 @@ # 更新日志 +## 1.15.0 + +- 维护失败项按图片/相册保存安全原因,支持分页查看和仅重试失败项;字段同步失败不重复编码,刷新保留任务身份,过期请求和异类任务覆盖被拒绝。旧版未记录明细的失败计数会明确提示。 + +- 重建成功后为公开派生图生成新的缓存标识,前台主图、缩略图、WebP/AVIF变体和聚合字段同步更新;照片ID与物理路径不变,旧字段相册链接不改写。CDN需把查询参数纳入缓存键。 + +- 新增手机省流选择:跟随系统建议或手动切换,省流时不自动翻页、不预取相邻大图;主动看图和加载更多不受影响。后台标签页暂停额外加载并释放预取引用。 +- 新增管理员只读健康检查和隔离恢复演练步骤;原图保护与备份可恢复性明确要求人工核验,不修改服务器配置。 + ## 1.14.2 - 外部转换工具使用 argv 直接启动,超时终止实际工具进程;输出直连系统空设备,不因大量日志或 Windows 阻塞管道拖住请求。