From 26de1409a687804654693d40bb7cb76a452f6a48 Mon Sep 17 00:00:00 2001
From: Thregren <37279823+Thregren@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:05:50 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=B4=E6=8A=A4?=
=?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=E4=B8=8E=E5=8E=9F=E5=AD=90=E5=AD=97?=
=?UTF-8?q?=E6=AE=B5=E5=90=8C=E6=AD=A5=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=A4=9A?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=85=BC=E5=AE=B9=E9=AA=8C=E8=AF=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/ci.yml | 70 +++
README.md | 20 +-
tests/admin-workflow.php | 1 +
tests/field-sync.php | 207 +++++++++
tests/maintenance-client.cjs | 100 ++++
tests/maintenance-panel.php | 273 +++++++++++
tests/maintenance-state.php | 54 +++
tests/migration.php | 1 +
tests/orphan-performance.php | 48 ++
tests/repository.php | 8 +-
tests/schema-sqlite.php | 1 +
tests/test.php | 1 +
tests/typecho-integration.php | 427 ++++++++++++++++++
tests/upload-fallback.php | 1 +
.../InfinityTime/Lib/AdminRepository.php | 8 +-
.../InfinityTime/Lib/AdminWorkflow.php | 10 +-
usr/plugins/InfinityTime/Lib/Database.php | 34 ++
.../InfinityTime/Lib/ImageRepository.php | 109 ++++-
.../InfinityTime/Lib/MaintenanceState.php | 80 ++++
usr/plugins/InfinityTime/Plugin.php | 33 +-
usr/plugins/InfinityTime/assets/admin.js | 12 +-
usr/plugins/InfinityTime/panel.php | 146 +++---
usr/themes/InfinityTime/index.php | 2 +-
usr/themes/InfinityTime/releases.json | 2 +-
usr/themes/InfinityTime/update.md | 8 +
25 files changed, 1529 insertions(+), 127 deletions(-)
create mode 100644 tests/field-sync.php
create mode 100644 tests/maintenance-client.cjs
create mode 100644 tests/maintenance-panel.php
create mode 100644 tests/maintenance-state.php
create mode 100644 tests/orphan-performance.php
create mode 100644 tests/typecho-integration.php
create mode 100644 usr/plugins/InfinityTime/Lib/Database.php
create mode 100644 usr/plugins/InfinityTime/Lib/MaintenanceState.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c7a7259..8d62b52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,11 @@ jobs:
php tests/repository.php
php tests/migration.php
php tests/schema-sqlite.php
+ php tests/field-sync.php
+ php tests/maintenance-state.php
+ for mode in rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do
+ php tests/maintenance-panel.php "$mode"
+ done
php tests/admin-workflow.php
php tests/admin-panel.php
for mode in get-maintenance post-no-token post-wrong-token post-array-token post-token-unavailable post-non-album post-foreign-album post-contributor-published; do
@@ -48,6 +53,7 @@ jobs:
php tests/upload-fallback.php
php tests/theme-output.php
node tests/admin-upload.cjs
+ node tests/maintenance-client.cjs
node tests/gallery.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs
@@ -86,3 +92,67 @@ jobs:
- name: 后台上传浏览器回归
if: ${{ !cancelled() }}
run: node tests/admin-upload.cjs --browser
+
+ typecho-integration:
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.4', '8.2', '8.4']
+ typecho:
+ - version: '1.2.1'
+ ref: '206880ba714d80bfb0638aeacb49c58b8fb1b327'
+ - version: '1.3.0'
+ ref: 'e9f23304bf20c0c355ef1df9634d42148cbc53b5'
+ database: [sqlite, mysql, pgsql]
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_DATABASE: infinitytime_test
+ MYSQL_ROOT_PASSWORD: integration-test-only
+ ports: ['3306:3306']
+ options: >-
+ --health-cmd="mysqladmin ping -h localhost -pintegration-test-only"
+ --health-interval=10s --health-timeout=5s --health-retries=10
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_DB: infinitytime_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: integration-test-only
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd="pg_isready -U postgres -d infinitytime_test"
+ --health-interval=10s --health-timeout=5s --health-retries=10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/checkout@v4
+ with:
+ repository: typecho/typecho
+ ref: ${{ matrix.typecho.ref }}
+ path: .typecho-test
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo_sqlite, sqlite3, pdo_mysql, mysqli, pdo_pgsql, pgsql
+ coverage: none
+ - name: PHP 代码与集成测试语法
+ run: find usr tests -name '*.php' -print0 | xargs -0 -n1 php -l
+ - name: 真实 Typecho 数据库集成
+ env:
+ TYPECHO_ROOT: ${{ github.workspace }}/.typecho-test
+ IT_DB_HOST: 127.0.0.1
+ IT_DB_NAME: infinitytime_test
+ IT_DB_USER: ${{ matrix.database == 'mysql' && 'root' || 'postgres' }}
+ IT_DB_PASSWORD: integration-test-only
+ run: |
+ case '${{ matrix.database }}' in
+ sqlite) adapters='Pdo_SQLite SQLite' ;;
+ mysql) adapters='Pdo_Mysql Mysqli' ;;
+ pgsql) adapters='Pdo_Pgsql Pgsql' ;;
+ esac
+ for adapter in $adapters; do
+ php tests/typecho-integration.php "$adapter"
+ done
diff --git a/README.md b/README.md
index c4c3814..bee6203 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
## 功能特性
+### v1.14.1 性能与可靠性加固
+
+维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。
+
### v1.14.0 相册工作流与浏览增强
- **先存草稿,再明确发布**:创建后保持草稿,逐张上传并显示「待上传 / 上传中 / 转换中 / 完成 / 失败」。只有点击发布才会公开;贡献者可管理自己的草稿,公开发布与已发布相册修改需要编辑者或管理员权限。
@@ -74,7 +78,7 @@
- **联系方式 / 联系我**:可视化选图标、增删/启停(AJAX)。
- **WebP 转换设置**:按 **通用保留原图 / 缩略图 / 普通图 / 全景图** 分组;宽度、**质量滑块**;转换工具状态;「恢复默认最佳设置」(AJAX)。
- **维护**:重建缩略图/全图、清理孤儿文件、重建尺寸字段、**清理非插件文章**;进度实时刷新,刷新页面后可继续未完成任务,并带并发锁。
-- **安全加固**:写操作带 CSRF token(含 GET 维护任务)、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。
+- **安全加固**:所有写操作与维护任务仅接受带 CSRF token 的 POST、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。
## 技术栈
@@ -88,12 +92,16 @@ php tests/sanitizer-fallback.php
php tests/repository.php
php tests/migration.php
php tests/schema-sqlite.php
+php tests/field-sync.php
+php tests/maintenance-state.php
+php tests/maintenance-panel.php
php tests/admin-workflow.php
php tests/admin-panel.php
php tests/gallery-discovery.php
php tests/upload-fallback.php
php tests/theme-output.php
node tests/admin-upload.cjs
+node tests/maintenance-client.cjs
node tests/gallery.cjs
node tests/gallery-loading.cjs
node tests/gallery-navigation.cjs
@@ -109,7 +117,15 @@ node tests/gallery-loading-browser.cjs
node tests/admin-upload.cjs --browser
```
-CI 同时检查 PHP / JS 语法、版本一致性和更新记录。仓库测试使用隔离临时文件及 DB/编码器替身;后台脚本测试模拟 DOM,覆盖预览、拖拽、移除和无 `DataTransfer` 时的提交顺序。这些检查不能替代真实 Typecho 后台及图片编码器的端到端验证。
+CI 同时检查 PHP / JS 语法、版本一致性和更新记录,并在 PHP 7.4 / 8.2 / 8.4、Typecho 1.2.1 / 1.3.0、SQLite / MySQL 8 / PostgreSQL 17 矩阵中使用官方数据库层与 PDO/原生适配器执行集成测试。测试 Typecho 源码固定到官方提交,覆盖草稿、权限、重试、字段回滚、外层事务及旧库升级。数据库以外的 options/编码器边界仍使用隔离替身,不能冒充真实网站、生产主从延迟或完整图片编码器的端到端验证。
+
+真实数据库测试仅在一次性测试库运行,例如:`TYPECHO_ROOT=/path/to/typecho php tests/typecho-integration.php Pdo_SQLite`。其他适配器为 `SQLite`、`Pdo_Mysql`、`Mysqli`、`Pdo_Pgsql`、`Pgsql`,连接参数使用 `IT_DB_HOST`、`IT_DB_PORT`、`IT_DB_NAME`、`IT_DB_USER`、`IT_DB_PASSWORD` 环境变量。测试会创建随机前缀的表并在结束时清理。
+
+字段聚合同步使用主库保存点:失败保留完整旧字段,不提交或回滚调用方的其他事务。MySQL 的 `fields` 表必须使用 InnoDB;非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。
+
+维护检查点有独立任务标识。未完成的同类任务继续原清单与进度,完成后再次开始会重新收集候选;迟到轮询不能覆盖新任务。磁盘写入失败会明确报错,重建字段失败计入失败数,可运行「重建尺寸字段」重试。首批已完成但响应丢失后,手动再次开始属于新任务,可能重复处理,但不会复用过期快照。
+
+孤儿引用扫描仅读六个路径列,不缓存旧引用;每批删除前仍在媒体锁内核验。可运行 `php tests/orphan-performance.php` 在隔离 SQLite 中对比返回量和查询中位数,测试不使用机器相关的耗时通过阈值。
维护与上传现在通过同一上传根目录内的文件锁互斥,媒体处理串行执行,逐图队列在忙时保留状态并提示重试,不会并发启动多张转换。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。
diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php
index d472238..614f285 100644
--- a/tests/admin-workflow.php
+++ b/tests/admin-workflow.php
@@ -110,6 +110,7 @@ public static function syncPostFields($cid): void {
use TypechoPlugin\InfinityTime\Lib\AdminRepository as Repository;
use TypechoPlugin\InfinityTime\Lib\AdminWorkflow as Workflow;
use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images;
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminSecurity.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminRepository.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminWorkflow.php';
diff --git a/tests/field-sync.php b/tests/field-sync.php
new file mode 100644
index 0000000..46b2daa
--- /dev/null
+++ b/tests/field-sync.php
@@ -0,0 +1,207 @@
+kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; }
+ public function from($table): self { $this->table = $table; return $this; }
+ public function rows($rows): self { $this->rows = $rows; return $this; }
+ public function where($clause, ...$values): self {
+ foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); }
+ $this->where[] = '(' . $clause . ')'; return $this;
+ }
+ public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; }
+ public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; }
+ public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; }
+ public function prepare(string $sql): string {
+ return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql);
+ }
+ public function __toString(): string {
+ $table = '"' . $this->table . '"';
+ if ($this->kind === 'insert') {
+ $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows)));
+ $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows)));
+ $sql = "INSERT INTO $table ($names) VALUES ($values)";
+ } elseif ($this->kind === 'update') {
+ $values = [];
+ foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); }
+ $sql = "UPDATE $table SET " . implode(',', $values);
+ } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; }
+ else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; }
+ return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit);
+ }
+ public function isInsert(): bool { return $this->kind === 'insert'; }
+ }
+ class Db {
+ public const SORT_ASC = 'ASC', WRITE = 2, READ = 1;
+ public static self $instance;
+ public \PDO $pdo;
+ public \PDO $replica;
+ public array $operations = [];
+ public string $adapter = 'Pdo_SQLite';
+ public $beforeWrite = null;
+ public function __construct() {
+ $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ $this->replica = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
+ }
+ public static function get(): self { return self::$instance; }
+ public function getPrefix(): string { return 'sync_'; }
+ public function getAdapterName(): string { return $this->adapter; }
+ public function selectDb($op) { return $op === self::WRITE ? $this->pdo : $this->replica; }
+ public function select(...$fields): Query { return new Query('select', '', $fields); }
+ public function insert($table): Query { return new Query('insert', $table); }
+ public function delete($table): Query { return new Query('delete', $table); }
+ public function fetchRow($query) { return $this->query($query)->fetch(\PDO::FETCH_ASSOC); }
+ public function fetchAll($query): array { return $this->query($query)->fetchAll(\PDO::FETCH_ASSOC); }
+ public function query($query, $op = self::READ) {
+ if ($query instanceof \PDOStatement) { return $query; }
+ if ($query instanceof Query) {
+ // 真实 Db::query 覆盖传入的 WRITE;未编译的 SELECT 应故意去空副本失败。
+ $op = preg_match('/^SELECT /', (string)$query) ? self::READ : self::WRITE;
+ }
+ $sql = (string)$query;
+ $this->operations[] = ['sql' => $sql, 'op' => $op, 'object' => is_object($query)];
+ if ($this->beforeWrite && preg_match('/^(INSERT|DELETE)/', $sql)) {
+ $callback = $this->beforeWrite;
+ $this->beforeWrite = null;
+ $callback();
+ }
+ return $this->selectDb($op)->query($sql);
+ }
+ }
+}
+namespace TypechoPlugin\InfinityTime {
+ class Plugin {
+ public static int $migrations = 0;
+ public static function migrateSchema(): void { self::$migrations++; throw new \RuntimeException('事务内禁止 DDL'); }
+ public static function log($message): void {}
+ }
+}
+namespace {
+ use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository;
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
+ require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php';
+ $root = sys_get_temp_dir() . '/infinitytime-sync-' . bin2hex(random_bytes(8));
+ mkdir($root, 0700);
+ define('__TYPECHO_ROOT_DIR__', $root);
+ define('__TYPECHO_UPLOAD_ROOT_DIR__', $root);
+ $db = \Typecho\Db::$instance = new \Typecho\Db();
+ $pdo = $db->pdo;
+ $checks = 0;
+ function expect(bool $ok, string $message): void {
+ global $checks;
+ if (!$ok) { throw new \RuntimeException($message); }
+ $checks++;
+ }
+ function fields(): array {
+ global $pdo;
+ return $pdo->query('SELECT * FROM sync_fields ORDER BY cid,name')->fetchAll(\PDO::FETCH_ASSOC);
+ }
+ function failSync(): void {
+ $thrown = false;
+ try { Repository::syncPostFields(1); }
+ catch (\Throwable $e) { $thrown = strpos($e->getMessage(), 'injected failure') !== false; }
+ expect($thrown, '同步应传播中途写入错误');
+ }
+ function failTrigger(bool $on, string $field = 'dims'): void {
+ global $pdo;
+ if ($on) {
+ $pdo->exec("CREATE TRIGGER fail_sync BEFORE INSERT ON sync_fields WHEN NEW.name = '$field' BEGIN SELECT RAISE(ABORT, 'injected failure'); END");
+ } else { $pdo->exec('DROP TRIGGER fail_sync'); }
+ }
+ try {
+ $resource = $pdo->query('SELECT 1');
+ expect(\TypechoPlugin\InfinityTime\Lib\Database::query($resource) === $resource, '已执行的 PDOStatement 句柄原样返回,不误调用 getAttribute(action)');
+ $resource->closeCursor();
+ $pdo->exec('CREATE TABLE sync_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, int_value INTEGER DEFAULT 0, float_value REAL DEFAULT 0, PRIMARY KEY(cid,name))');
+ $pdo->exec('CREATE TABLE sync_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, full TEXT, thumb TEXT, width INTEGER, height INTEGER, exif TEXT, created INTEGER)');
+ $pdo->exec("INSERT INTO sync_infinitytime_images VALUES (1,1,0,'/new.webp','/new-thumb.webp',200,100,'{}',1704067200),(2,2,0,'/second.webp','/second-thumb.webp',100,100,'{}',1704067200)");
+ foreach ([1,2] as $cid) {
+ foreach (['img','thumb','addresses','titles','descs','panos','dims','variants','exif','photo_ids','months','unrelated'] as $name) {
+ $q = $pdo->prepare('INSERT INTO sync_fields VALUES (?,?,?,?,?,?)');
+ $q->execute([$cid,$name,'str','old-' . $name,11,1.25]);
+ }
+ }
+ $original = fields();
+ failTrigger(true);
+ failSync();
+ expect(fields() === $original, '独立事务中途失败必须完整保留所有旧字段及类型列');
+ failTrigger(false);
+ $pdo->exec('BEGIN');
+ $pdo->exec("UPDATE sync_fields SET str_value = 'caller-before' WHERE cid=1 AND name='unrelated'");
+ $outer = fields();
+ failTrigger(true);
+ failSync();
+ expect(fields() === $outer, '嵌套失败不得回滚调用方此前的修改');
+ failTrigger(false);
+ $pdo->exec("UPDATE sync_fields SET str_value = 'caller-after' WHERE cid=2 AND name='unrelated'");
+ $pdo->exec('COMMIT');
+ expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=2 AND name='unrelated'")->fetchColumn() === 'caller-after', '失败后外层事务应可继续并提交');
+
+ $beforeSuccess = fields();
+ $pdo->exec('BEGIN');
+ Repository::syncPostFields(1);
+ expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='img'")->fetchColumn() === '/new.webp', '同步使用主库图片行');
+ $pdo->exec('ROLLBACK');
+ expect(fields() === $beforeSuccess, '成功同步不得提前提交外层 BEGIN');
+
+ $pdo->exec('SAVEPOINT caller');
+ Repository::syncPostFields(1);
+ Repository::syncPostFields(2);
+ $pdo->exec('ROLLBACK TO SAVEPOINT caller');
+ $pdo->exec('RELEASE SAVEPOINT caller');
+ expect(fields() === $beforeSuccess, '连续同步不能越过调用方 SAVEPOINT 边界');
+
+ $db->beforeWrite = static function (): void {
+ Repository::syncPostFields(2);
+ throw new \RuntimeException('injected failure');
+ };
+ failSync();
+ expect(fields() === $beforeSuccess, '重入同步独立命名保存点,外层失败回滚内部成功同步');
+
+ Repository::syncPostFields(1);
+ $afterSuccess = fields();
+ expect($afterSuccess !== $beforeSuccess, '独立同步成功应持久化');
+ expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='dims'")->fetchColumn() === '["200x100"]', '字段内容与图片顺序一致');
+ expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='unrelated'")->fetchColumn() === 'caller-before', '同步不改无关字段');
+ $pdo->exec('ALTER TABLE sync_infinitytime_images ADD COLUMN title TEXT');
+ $badTitle = $pdo->prepare('UPDATE sync_infinitytime_images SET title=? WHERE cid=1');
+ $badTitle->execute(["bad-\xff"]);
+ $badJson = false;
+ try { Repository::syncPostFields(1); } catch (\JsonException $e) { $badJson = true; }
+ expect($badJson && fields() === $afterSuccess, 'JSON编码错误也须完整回滚,不能把false写入字段');
+ $pdo->exec('DELETE FROM sync_infinitytime_images WHERE cid=1');
+ Repository::syncPostFields(1);
+ expect(fields() === $afterSuccess, '历史字段无图片行时保持原样');
+ failTrigger(true, 'thumb');
+ $clearFailed = false;
+ try { Repository::syncPostFields(1, true); } catch (\Throwable $e) { $clearFailed = true; }
+ expect($clearFailed && fields() === $afterSuccess, '清空字段中途失败也须保留全部旧字段');
+ failTrigger(false);
+ Repository::syncPostFields(1, true);
+ $empty = $pdo->query("SELECT name,str_value FROM sync_fields WHERE cid=1 AND name <> 'unrelated' ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
+ expect($empty === [['name'=>'img','str_value'=>''],['name'=>'thumb','str_value'=>'']], '显式清空保留空图集标记并清除旧元数据');
+ expect(\TypechoPlugin\InfinityTime\Plugin::$migrations === 0, '同步不得触发 schema DDL 或隐式提交');
+ foreach ($db->operations as $operation) {
+ expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '同步的查询和事务语句全部在主库执行');
+ }
+ $db->adapter = 'unknown';
+ $beforeUnsupported = fields();
+ $rejected = false;
+ try { Repository::syncPostFields(2); } catch (\RuntimeException $e) { $rejected = strpos($e->getMessage(), '不支持') !== false; }
+ expect($rejected && fields() === $beforeUnsupported, '未知适配器安全拒绝且不修改字段');
+ echo "字段同步原子性、嵌套事务与主库路由检查通过($checks 项)\n";
+ } finally {
+ Repository::unlockMedia();
+ @unlink($root . '/.infinitytime-media.lock');
+ @rmdir($root);
+ }
+}
diff --git a/tests/maintenance-client.cjs b/tests/maintenance-client.cjs
new file mode 100644
index 0000000..bb58184
--- /dev/null
+++ b/tests/maintenance-client.cjs
@@ -0,0 +1,100 @@
+// 使用真实后台脚本和可控 DOM/网络/定时器,验证维护任务协议及故障恢复。
+const assert = require('node:assert/strict');
+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 = ''; }
+ addEventListener(name, listener) { this.listeners[name] = listener; }
+ emit(name) { this.listeners[name]?.({ target: this }); }
+ getAttribute(name) { return name === 'data-run' ? '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; }
+}
+class FormDataMock {
+ constructor() { this.fields = new Map(); }
+ set(name, value) { this.fields.set(name, value); }
+ append(name, value) { this.set(name, value); }
+ get(name) { return this.fields.get(name); }
+}
+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 listeners = {}, requests = [], timers = [], replies = [];
+ const document = {
+ querySelector: selector => nodes[selector] || null,
+ querySelectorAll: selector => selector === '[data-run]' ? [button] : [],
+ addEventListener: (name, listener) => { listeners[name] = listener; },
+ createElement: () => new Element(),
+ };
+ vm.runInNewContext(source, {
+ window: { PP_ADMIN: { url: '/admin/panel', token: 'fixture-token', job } }, document, FormData: FormDataMock,
+ setTimeout: (fn, delay) => { timers.push({ fn, delay }); },
+ fetch: (url, options) => {
+ requests.push({ url, ...options });
+ return new Promise((resolve, reject) => replies.push({ resolve, reject }));
+ },
+ });
+ listeners.DOMContentLoaded();
+ return { button, bar, message, container, requests, timers,
+ 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'); } }); },
+ poll() { assert.equal(timers.length, 1, '每次只能排队一次轮询'); const timer = timers.shift(); assert.equal(timer.delay, 300); timer.fn(); },
+ };
+}
+(async () => {
+ const h = harness();
+ h.button.emit('click'); h.button.emit('click');
+ assert.equal(h.requests.length, 1, '双击不得启动重复任务');
+ assert.equal(h.button.disabled, true, '运行时禁用启动按钮');
+ const first = h.requests[0];
+ assert.equal(first.method, 'POST', '维护任务必须使用 POST');
+ assert.equal(first.credentials, 'same-origin', '维护请求必须保留同源凭据');
+ for (const [key, value] of Object.entries({ action: 'maintenance', job: 'rebuild', mode: 'start', job_id: '', _: 'fixture-token', ajax: '1' })) {
+ assert.equal(first.body.get(key), value, '开始请求字段正确:' + key);
+ }
+ h.reply({ job_id: 'current-job', total: 5, done: 3, failed: 0, finished: false }); await flush();
+ assert.equal(h.bar.style.width, '60%', '进度反映已完成比例');
+ assert.equal(h.button.disabled, true, '批次之间保持按钮禁用');
+ h.poll();
+ assert.equal(h.requests[1].body.get('mode'), 'poll', '后续请求必须改为轮询');
+ 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.equal(h.button.disabled, false, '完成后恢复启动按钮');
+ assert.equal(h.timers.length, 0, '完成后不再轮询');
+ h.button.emit('click');
+ assert.equal(h.requests[2].body.get('mode'), 'start', '再次点击必须开始新任务');
+ assert.equal(h.requests[2].body.get('job_id'), '', '再次开始不得带上旧任务身份');
+ assert.equal(h.bar.style.width, '0%', '新任务清除旧进度');
+ h.reply({ job_id: 'fresh-job', total: 0, done: 0, failed: 0, finished: true }); await flush();
+ assert.match(h.message.textContent, /没有需要重建的图片/, '空任务显示明确说明');
+
+ const resumed = harness({ job: 'rebuild', job_id: 'saved-job', total: 6, done: 3, finished: false });
+ resumed.container.child.querySelector('button').emit('click');
+ assert.equal(resumed.requests[0].body.get('mode'), 'resume', '继续入口必须发送 resume');
+ assert.equal(resumed.requests[0].body.get('job_id'), 'saved-job', '继续入口必须使用持久化任务身份');
+ resumed.reply({ ok: false, msg: '维护任务已更新' }, false); await flush();
+ assert.equal(resumed.message.textContent, '维护任务已更新', '过期任务拒绝必须显示真实错误');
+ assert.equal(resumed.button.disabled, false, '拒绝后允许重新开始');
+ assert.equal(resumed.timers.length, 0, '过期任务不得继续轮询');
+
+ for (const fault of ['network', 'malformed', 'lease']) {
+ const broken = harness(); broken.button.emit('click');
+ if (fault === 'network') broken.reject();
+ if (fault === 'malformed') broken.malformed();
+ if (fault === 'lease') broken.reply({ finished: true, msg: '另一个维护任务正在进行' });
+ await flush();
+ assert.equal(broken.button.disabled, false, '故障或租约拒绝必须恢复按钮:' + fault);
+ assert.equal(broken.timers.length, 0, '故障或租约拒绝必须停止轮询:' + fault);
+ assert.ok(broken.message.textContent && !broken.message.textContent.includes('✓'), '故障不得显示成功:' + fault);
+ broken.button.emit('click');
+ assert.equal(broken.requests.length, 2, '故障必须释放运行状态以允许重试:' + fault);
+ broken.reply({ total: 0, done: 0, failed: 0, finished: true }); await flush();
+ }
+ console.log('维护前端:开始/轮询/续跑身份、批次、重复点击、失败可见及错误恢复检查通过');
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/maintenance-panel.php b/tests/maintenance-panel.php
new file mode 100644
index 0000000..9a7ddd4
--- /dev/null
+++ b/tests/maintenance-panel.php
@@ -0,0 +1,273 @@
+(默认 rebuild-sync-failure)。
+ * 测试复制未经修改的面板到独立临时目录,不接触插件真实 data 目录。 */
+declare(strict_types=1);
+namespace Typecho {
+ class Query {
+ private string $kind = 'select';
+ private string $table = '';
+ private array $fields = [];
+ private array $rows = [];
+ private array $where = [];
+ private array $params = [];
+ private string $order = '';
+ private string $limit = '';
+ public function __construct(string $kind = 'select', string $table = '', array $fields = []) { $this->kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; }
+ public function from($table): self { $this->table = $table; return $this; }
+ public function rows($rows): self { $this->rows = $rows; return $this; }
+ public function where($clause, ...$values): self {
+ foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); }
+ $this->where[] = '(' . $clause . ')'; return $this;
+ }
+ public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; }
+ public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; }
+ public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; }
+ public function prepare(string $sql): string {
+ return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql);
+ }
+ public function __toString(): string {
+ $table = '"' . $this->table . '"';
+ if ($this->kind === 'insert') {
+ $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows)));
+ $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows)));
+ $sql = "INSERT INTO $table ($names) VALUES ($values)";
+ } elseif ($this->kind === 'update') {
+ $values = [];
+ foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); }
+ $sql = "UPDATE $table SET " . implode(',', $values);
+ } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; }
+ else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; }
+ return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit);
+ }
+ public function isInsert(): bool { return $this->kind === 'insert'; }
+ }
+ class Db {
+ public const SORT_ASC = 'ASC', WRITE = 'WRITE', READ = 'READ';
+ public static self $instance;
+ public \PDO $pdo;
+ public array $reads = [];
+ public array $operations = [];
+ public bool $failDelete = 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; }
+ public function getPrefix(): string { return 'admin_'; }
+ public function getAdapterName(): string { return $this->adapter; }
+ public function select(...$fields): Query { return new Query('select', '', $fields); }
+ public function insert($table): Query { return new Query('insert', $table); }
+ public function update($table): Query { return new Query('update', $table); }
+ public function delete($table): Query { return new Query('delete', $table); }
+ public function fetchRow($query) { if ($query instanceof \PDOStatement) { return $query->fetch(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetch(\PDO::FETCH_ASSOC); }
+ 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 (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);
+ return $query instanceof Query && $query->isInsert() ? (int)$this->pdo->lastInsertId() : $affected;
+ }
+ }
+}
+namespace Widget {
+ class User {
+ public int $uid = 7;
+ public static function alloc(): self { return new self(); }
+ public function pass($role, $return): bool { return true; }
+ }
+ class Security {
+ public static function alloc(): self { return new self(); }
+ public function getToken($scope): string { return 'fixture-maintenance-token'; }
+ }
+}
+namespace Utils {
+ class Helper {
+ public static function options(): object { return (object)['siteUrl' => 'https://example.test/']; }
+ }
+}
+namespace TypechoPlugin\InfinityTime {
+ class Plugin {
+ public const DEFAULT_QUALITY = 76, DEFAULT_THUMB_MAX = 1280, DEFAULT_MAX_WIDTH = 2560,
+ DEFAULT_FULL_QUALITY = 82, DEFAULT_PANO_WIDTH = 0, DEFAULT_PANO_QUALITY = 92;
+ public static array $logs = [];
+ public static function opt($name, $default) { return $default; }
+ public static function log($message): void { self::$logs[] = $message; }
+ }
+}
+namespace TypechoPlugin\InfinityTime\Lib {
+ class ImageRepository {
+ public static string $root;
+ public static bool $locked = false;
+ public static bool $failSync = false;
+ public static array $updated = [];
+ public static array $synced = [];
+ public static function lockMedia(): void { self::$locked = true; }
+ public static function table(): string { return 'admin_infinitytime_images'; }
+ public static function toAbs(string $path): string { return self::$root . '/' . ltrim($path, '/'); }
+ public static function isPano(int $w, int $h): bool { return $w > $h * 2; }
+ public static function updateVariants(int $id, array $result): void {
+ if (!self::$locked) { throw new \RuntimeException('变体更新必须持有媒体锁'); }
+ self::$updated[] = $id;
+ }
+ public static function syncPostFields(int $cid): void {
+ if (!self::$locked) { throw new \RuntimeException('字段同步必须持有媒体锁'); }
+ self::$synced[] = $cid;
+ if (self::$failSync) { throw new \RuntimeException('模拟字段同步故障'); }
+ }
+ }
+ class MediaProcessor {
+ public static bool $fail = false;
+ public static int $calls = 0;
+ public static function process(...$args): array {
+ self::$calls++;
+ if (self::$fail) { throw new \RuntimeException('模拟编码故障'); }
+ return ['width' => 1, 'height' => 1];
+ }
+ }
+}
+namespace {
+ use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images;
+ use TypechoPlugin\InfinityTime\Lib\MaintenanceState as State;
+ use TypechoPlugin\InfinityTime\Lib\MediaProcessor as Media;
+ use TypechoPlugin\InfinityTime\Plugin;
+ function _t($message): string { return $message; }
+ function expect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }
+ function removeFixture(string $dir): void {
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) {
+ if ($item->isDir()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); }
+ }
+ rmdir($dir);
+ }
+ $mode = $argv[1] ?? 'rebuild-sync-failure';
+ $modes = ['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'];
+ expect(in_array($mode, $modes, true), '未知测试模式:' . $mode);
+ $source = dirname(__DIR__) . '/usr/plugins/InfinityTime';
+ require $source . '/Lib/Database.php';
+ require $source . '/Lib/AdminSecurity.php';
+ require $source . '/Lib/AdminRepository.php';
+ require $source . '/Lib/MaintenanceState.php';
+ $root = sys_get_temp_dir() . '/infinity-maintenance-panel-' . bin2hex(random_bytes(8));
+ mkdir($root, 0700); mkdir($root . '/Lib', 0700); mkdir($root . '/data', 0700);
+ copy($source . '/panel.php', $root . '/panel.php');
+ // 继续使用已加载的真实状态类,避免重复声明。
+ file_put_contents($root . '/Lib/MaintenanceState.php', 'query('CREATE TABLE admin_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, original TEXT, full TEXT, thumb TEXT)');
+ $db->query('CREATE TABLE admin_contents (cid INTEGER PRIMARY KEY, title TEXT, type TEXT)');
+ $db->query('CREATE TABLE admin_fields (cid INTEGER, name TEXT, str_value TEXT)');
+ $db->query("INSERT INTO admin_infinitytime_images VALUES (1,10,'/original.png','/full.webp','/thumb.webp')");
+ $oldId = str_repeat('a', 32);
+ $state = ['job' => 'rebuild', 'job_id' => $oldId, 'total' => 3, 'done' => 1, 'failed' => 0, 'current' => 'old', 'finished' => false];
+ $_SERVER['REQUEST_METHOD'] = 'POST'; $_GET = []; $_FILES = [];
+ $_POST = ['action' => 'maintenance', 'job' => 'rebuild', 'mode' => 'start', 'ajax' => '1', '_' => 'fixture-maintenance-token'];
+ if ($mode === 'rebuild-batch') {
+ for ($id = 2; $id <= 5; $id++) {
+ $db->query("INSERT INTO admin_infinitytime_images VALUES ($id,10,'/original.png','/full$id.webp','/thumb$id.webp')");
+ }
+ }
+ if ($mode === 'rebuild-sync-failure') { Images::$failSync = true; }
+ if ($mode === 'rebuild-conversion-failure') { Media::$fail = true; }
+ if ($mode === 'rebuild-no-original') { $db->query("UPDATE admin_infinitytime_images SET original = ''"); }
+ if ($mode === 'rebuild-missing-original') { unlink($root . '/original.png'); }
+ if (in_array($mode, ['rebuild-restart', 'rebuild-resume', 'rebuild-start-reuse', 'stale-poll', 'finished-poll'], true)) {
+ if (in_array($mode, ['rebuild-restart', 'finished-poll'], true)) { $state['done'] = 3; $state['finished'] = true; }
+ State::write($root . '/data/job.json', $state);
+ State::write(State::listFile($root . '/data', $state), [1, 2, 3]);
+ // 快照中的第 2 行已删除,第 3 行仍存在,第 4 行是快照之后的新图片。
+ $db->query("INSERT INTO admin_infinitytime_images VALUES (3,30,'/original.png','/full3.webp','/thumb3.webp'),(4,40,'/original.png','/full4.webp','/thumb4.webp')");
+ if ($mode === 'rebuild-resume') { $_POST['mode'] = 'resume'; $_POST['job_id'] = $oldId; }
+ if (in_array($mode, ['stale-poll', 'finished-poll'], true)) {
+ $_POST['mode'] = 'poll'; $_POST['job_id'] = $mode === 'stale-poll' ? str_repeat('b', 32) : $oldId;
+ State::write($root . '/data/job.lock', ['uid' => 7, 'job' => 'rebuild', 'time' => time() - 10]);
+ }
+ }
+ if (in_array($mode, ['delete-non-plugin', 'delete-rollback'], true)) {
+ $_POST = ['action' => 'delete_non_plugin', 'ajax' => '1', '_' => 'fixture-maintenance-token'];
+ $db->query("INSERT INTO admin_contents VALUES (10,'Image-backed','post'),(11,'Marked draft','post'),(12,'Legacy album','post'),(13,'Ordinary post','post'),(14,'Page','page')");
+ $db->query("INSERT INTO admin_fields VALUES (11,'infinitytime_album','1'),(12,'img','legacy.webp'),(13,'description','ordinary')");
+ $db->failDelete = $mode === 'delete-rollback';
+ }
+ if ($mode === 'lease-conflict') {
+ State::write($root . '/data/job.lock', ['uid' => 7, 'job' => 'cleanup', 'time' => time()]);
+ }
+ $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/*');
+ $db->operations = []; $db->reads = [];
+ define('__TYPECHO_ADMIN__', true);
+ ob_start();
+ register_shutdown_function(static function () use ($root, $mode, $db, $oldId, $beforeState, $beforeLock, $beforeFiles) {
+ $output = ob_get_clean();
+ try {
+ $json = json_decode($output, true);
+ expect(is_array($json), '响应必须为 JSON:' . $output);
+ foreach ($db->operations as $operation) {
+ if (strpos($operation['sql'], 'SELECT ') === 0 || in_array($operation['sql'], ['BEGIN', 'COMMIT', 'ROLLBACK'], true)) {
+ expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '快照、实时读取与事务必须使用主库连接');
+ }
+ }
+ if ($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 === [], '租约冲突必须在数据库读取和媒体处理前停止');
+ } elseif (in_array($mode, ['stale-poll', 'finished-poll'], true)) {
+ expect(file_get_contents($root . '/data/job.json') === $beforeState, '轮询不得重写检查点');
+ expect(file_get_contents($root . '/data/job.lock') === $beforeLock, '轮询不得刷新或删除锁');
+ expect(glob($root . '/data/*') === $beforeFiles, '轮询不得创建状态文件');
+ expect(Media::$calls === 0 && Images::$synced === [], '轮询不得处理过期或已完成任务');
+ expect($mode === 'stale-poll' ? (http_response_code() === 500 && $json['ok'] === false) : $json['finished'] === true, '轮询响应必须报告拒绝或完成');
+ } elseif (in_array($mode, ['delete-non-plugin', 'delete-rollback'], true)) {
+ $remaining = $db->pdo->query('SELECT cid FROM admin_contents ORDER BY cid')->fetchAll(PDO::FETCH_COLUMN);
+ $remaining = array_map('intval', $remaining);
+ $sql = array_column($db->operations, 'sql');
+ expect(count(array_filter($sql, static function ($q) { return strpos($q, 'SELECT ') === 0; })) === 3, '三类身份分类快照必须全部从主库读取');
+ expect(in_array('BEGIN', $sql, true), '删除必须开启事务');
+ if ($mode === 'delete-rollback') {
+ expect($json['ok'] === false && $remaining === [10, 11, 12, 13, 14], '删除失败必须保留每篇文章');
+ expect($db->pdo->query('SELECT COUNT(*) FROM admin_fields WHERE cid=13')->fetchColumn() == 1, '回滚必须恢复已删除的自定义字段');
+ expect(in_array('ROLLBACK', $sql, true) && !in_array('COMMIT', $sql, true), '删除失败必须回滚');
+ } else {
+ expect($json['ok'] === true && $json['deleted'] === 1 && $remaining === [10, 11, 12, 14], '仅删除普通文章,保留所有图集身份及页面');
+ expect(in_array('COMMIT', $sql, true), '成功删除必须提交事务');
+ }
+ } elseif ($mode === 'rebuild-batch') {
+ 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::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', '未完成任务必须保留用户及任务锁');
+ } else {
+ 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, '失败计数必须在响应中可见');
+ $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(!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, '重新开始必须生成新身份并包含新增图片'); }
+ if ($mode === 'rebuild-sync-failure') {
+ expect(strpos($json['current'], '字段同步失败') !== false && Images::$synced === [10], '同步失败必须可见并带修复提示');
+ expect(count(Plugin::$logs) === 1 && strpos(Plugin::$logs[0], '模拟字段同步故障') !== false, '同步异常必须记录日志');
+ }
+ }
+ echo '维护面板请求检查通过:' . $mode . "\n";
+ } catch (Throwable $e) {
+ fwrite(STDERR, '维护面板请求检查失败:' . $mode . ' ' . $e->getMessage() . "\n");
+ removeFixture($root);
+ exit(1);
+ }
+ removeFixture($root);
+ });
+ require $root . '/panel.php';
+ throw new RuntimeException('AJAX 请求意外进入页面渲染');
+}
diff --git a/tests/maintenance-state.php b/tests/maintenance-state.php
new file mode 100644
index 0000000..0ce97eb
--- /dev/null
+++ b/tests/maintenance-state.php
@@ -0,0 +1,54 @@
+ 'rebuild', 'total' => 2, 'done' => 1]);
+ State::write($dir . '/rebuild_list.json', [[21, 'old1'], [22, 'old2']]);
+ [$legacy, $list] = State::open($dir, 'rebuild', 'resume', '', $collect);
+ verify($legacy['done'] === 1 && strlen($legacy['job_id']) === 32 && $list[1][0] === 22, '旧断点必须平滑保留');
+ 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], '旧版本完成状态不得复用旧清单');
+ // rename 到目录必失败,不能以成功返回。
+ mkdir($dir . '/cannot-replace');
+ rejected(static function () use ($dir) { State::write($dir . '/cannot-replace', ['done' => 1]); }, 'rename失败必须可见');
+ verify(count(glob($dir . '/cannot-replace.tmp.*')) === 0, '失败写入必须清理临时文件');
+ file_put_contents($dir . '/broken.json', '{');
+ rejected(static function () use ($dir) { State::read($dir . '/broken.json'); }, '损坏状态不可静默当新任务');
+ echo '维护检查点:' . $checks . " 项检查通过\n";
+} finally {
+ foreach (glob($dir . '/*') as $file) { if (is_dir($file)) { rmdir($file); } else { unlink($file); } }
+ rmdir($dir);
+}
diff --git a/tests/migration.php b/tests/migration.php
index 8f46aa4..7793a4a 100644
--- a/tests/migration.php
+++ b/tests/migration.php
@@ -78,6 +78,7 @@ public static function options(): object { return self::$options; }
namespace {
interface Typecho_Plugin_Interface {}
require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php';
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php';
use TypechoPlugin\InfinityTime\Plugin;
use TypechoPlugin\InfinityTime\Lib\ImageRepository;
diff --git a/tests/orphan-performance.php b/tests/orphan-performance.php
new file mode 100644
index 0000000..2acc0d7
--- /dev/null
+++ b/tests/orphan-performance.php
@@ -0,0 +1,48 @@
+ PDO::ERRMODE_EXCEPTION]);
+$pdo->exec('CREATE TABLE images (id INTEGER PRIMARY KEY, cid INTEGER, original TEXT, full TEXT, thumb TEXT, mid TEXT, avif TEXT, mid_avif TEXT, exif TEXT, title TEXT, width INTEGER, height INTEGER)');
+$insert = $pdo->prepare('INSERT INTO images VALUES (?,?,?,?,?,?,?,?,?,?,?,?)');
+$exif = json_encode(['camera' => 'fixture', 'notes' => str_repeat('x', 5000)]);
+$pdo->beginTransaction();
+for ($id = 1; $id <= 10000; $id++) {
+ $insert->execute([$id, (int)ceil($id / 20), "/original/$id.jpg", "/full/$id.webp", "/thumb/$id.webp",
+ "/mid/$id.webp", "/full/$id.avif", "/mid/$id.avif", $exif, "Photo $id", 2400, 1600]);
+}
+$pdo->commit();
+$paths = ['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'];
+function measure(PDO $pdo, string $columns, array $paths): array {
+ $start = microtime(true);
+ $rows = $pdo->query("SELECT $columns FROM images ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
+ $elapsed = (microtime(true) - $start) * 1000;
+ $bytes = 0;
+ $digest = hash_init('sha256');
+ foreach ($rows as $row) {
+ $bytes += strlen(json_encode($row, JSON_UNESCAPED_SLASHES));
+ foreach ($paths as $path) { hash_update($digest, (string)$row[$path] . "\n"); }
+ }
+ return ['ms' => $elapsed, 'bytes' => $bytes, 'digest' => hash_final($digest), 'count' => count($rows)];
+}
+$all = [];
+$projected = [];
+for ($pass = 0; $pass < 5; $pass++) {
+ // 交替顺序减少首次查询与缓存偏差;耗时仅包含查询和 fetchAll。
+ if ($pass % 2 === 0) {
+ $all[] = measure($pdo, '*', $paths);
+ $projected[] = measure($pdo, implode(',', $paths), $paths);
+ } else {
+ $projected[] = measure($pdo, implode(',', $paths), $paths);
+ $all[] = measure($pdo, '*', $paths);
+ }
+ if ($all[$pass]['digest'] !== $projected[$pass]['digest'] || $projected[$pass]['count'] !== 10000) {
+ throw new RuntimeException('路径投影改变了引用路径');
+ }
+}
+$fullTimes = array_column($all, 'ms');
+$pathTimes = array_column($projected, 'ms');
+sort($fullTimes);
+sort($pathTimes);
+if ($projected[0]['bytes'] >= $all[0]['bytes']) { throw new RuntimeException('投影必须降低返回数据量'); }
+printf("10,000 rows, 5KB EXIF: SELECT * median %.2f ms, %d JSON bytes; six-path projection median %.2f ms, %d JSON bytes; all path digests identical (5 runs each)\n",
+ $fullTimes[2], $all[0]['bytes'], $pathTimes[2], $projected[0]['bytes']);
diff --git a/tests/repository.php b/tests/repository.php
index be72956..20d05cd 100644
--- a/tests/repository.php
+++ b/tests/repository.php
@@ -24,6 +24,8 @@ class Db {
public array $legacyFields = [];
public static function get(): self { return self::$instance; }
public function getPrefix(): string { return 'test_'; }
+ public function getAdapterName(): string { return 'Pdo_SQLite'; }
+ public function selectDb($op) { return $this; }
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); }
@@ -32,8 +34,11 @@ public function fetchAll($query): array { return $query->table === 'test_fields'
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'); }
- return unserialize(base64_decode(substr($query, 8)));
+ $query = unserialize(base64_decode(substr($query, 8)));
+ if ($query->kind === 'select') { return $query; }
}
+ // 此替身只测字段内容;真实事务边界由 field-sync.php 覆盖。
+ if (is_string($query)) { return; }
if ($query->table === 'test_infinitytime_images' && $query->kind === 'update') {
foreach ($this->images as &$image) {
if (($image['id'] ?? null) === $query->conditions['id = ?'][0]) { $image = array_merge($image, $query->values); }
@@ -73,6 +78,7 @@ public static function process(...$args): array {
use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository;
use TypechoPlugin\InfinityTime\Lib\MediaProcessor;
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php';
$root = sys_get_temp_dir() . '/infinitytime-test-' . bin2hex(random_bytes(8));
mkdir($root, 0700);
diff --git a/tests/schema-sqlite.php b/tests/schema-sqlite.php
index 26cfea4..f5fb51a 100644
--- a/tests/schema-sqlite.php
+++ b/tests/schema-sqlite.php
@@ -40,6 +40,7 @@ public static function options(): object { return self::$options; }
}
namespace {
interface Typecho_Plugin_Interface {}
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php';
use TypechoPlugin\InfinityTime\Plugin;
$db = \Typecho\Db::$instance = new \Typecho\Db();
diff --git a/tests/test.php b/tests/test.php
index f2d63e5..d598f47 100644
--- a/tests/test.php
+++ b/tests/test.php
@@ -5,6 +5,7 @@
*/
declare(strict_types=1);
+require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/Sanitizer.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/MediaProcessor.php';
diff --git a/tests/typecho-integration.php b/tests/typecho-integration.php
new file mode 100644
index 0000000..79e6536
--- /dev/null
+++ b/tests/typecho-integration.php
@@ -0,0 +1,427 @@
+ 'SQLite', 'SQLite' => 'SQLite', 'Pdo_Mysql' => 'Mysql',
+ 'Mysqli' => 'Mysql', 'Pdo_Pgsql' => 'Pgsql', 'Pgsql' => 'Pgsql'];
+ if (!isset($dialects[$adapter])) { throw new \InvalidArgumentException('Unsupported test adapter'); }
+ $dialect = $dialects[$adapter];
+ $prefix = 'it_test_' . bin2hex(random_bytes(6)) . '_';
+ $tmp = sys_get_temp_dir() . '/' . $prefix;
+ if (!mkdir($tmp, 0700)) { throw new \RuntimeException('Cannot create test directory'); }
+ define('__TYPECHO_ROOT_DIR__', $typecho);
+ define('__TYPECHO_PLUGIN_DIR__', '/usr/plugins');
+ define('__TYPECHO_UPLOAD_ROOT_DIR__', $tmp . '/uploads');
+ define('__TYPECHO_UPLOAD_DIR__', '/usr/uploads');
+ // Same compatibility alias as Typecho's Widget\Init, with the real interface.
+ define('__TYPECHO_CLASS_ALIASES__', ['Typecho_Plugin_Interface' => '\\Typecho\\Plugin\\PluginInterface']);
+ require $typecho . '/var/Typecho/Common.php';
+ spl_autoload_register(static function (string $class): void {
+ $namespace = 'TypechoPlugin\\InfinityTime\\';
+ if (strpos($class, $namespace) === 0) {
+ $file = __DIR__ . '/../usr/plugins/InfinityTime/' . str_replace('\\', '/', substr($class, strlen($namespace))) . '.php';
+ if (is_file($file)) { require_once $file; }
+ }
+ });
+ \Utils\Helper::$options = new \stdClass();
+ $db = new Db($adapter, $prefix);
+ Db::set($db);
+ $config = $dialect === 'SQLite' ? ['file' => $tmp . '/database.sqlite'] : [
+ 'host' => getenv('IT_DB_HOST') ?: '127.0.0.1',
+ 'port' => getenv('IT_DB_PORT') ?: ($dialect === 'Mysql' ? '3306' : '5432'),
+ 'database' => getenv('IT_DB_NAME') ?: 'infinitytime_test',
+ 'user' => getenv('IT_DB_USER') ?: ($dialect === 'Mysql' ? 'root' : 'postgres'),
+ 'password' => getenv('IT_DB_PASSWORD') ?: '',
+ 'charset' => $dialect === 'Mysql' ? 'utf8mb4' : 'UTF8',
+ ];
+ $db->addServer($config, Db::WRITE);
+ // pg_connect reuses identical connection strings; distinct host spelling gives a real reader.
+ if ($adapter === 'Pgsql' && $config['host'] === '127.0.0.1') { $config['host'] = 'localhost'; }
+ $db->addServer($config, Db::READ);
+
+ $checks = 0;
+ function check(bool $passed, string $message): void
+ {
+ global $checks;
+ if (!$passed) { throw new \RuntimeException($message); }
+ $checks++;
+ }
+ function rejected(callable $operation, string $exception, string $message): void
+ {
+ try { $operation(); } catch (\Throwable $e) {
+ check($e instanceof $exception, $message . ': wrong exception ' . get_class($e) . ': ' . $e->getMessage());
+ return;
+ }
+ check(false, $message . ': operation unexpectedly succeeded');
+ }
+ function injectedFailure(callable $operation, string $message, string $expected = 'Integration failure'): void
+ {
+ try { $operation(); } catch (\Throwable $e) {
+ // Upstream native Pgsql passes false to pg_result_error_field on SQL errors (PHP 8 TypeError).
+ global $adapter;
+ $upstreamPgError = $adapter === 'Pgsql' && strpos($e->getMessage(), 'pg_result_error_field') !== false;
+ check(stripos($e->getMessage(), $expected) !== false || $upstreamPgError, $message . ': unexpected error ' . $e->getMessage());
+ return;
+ }
+ check(false, $message . ': operation unexpectedly succeeded');
+ }
+ function table(string $name): string
+ {
+ global $prefix, $dialect;
+ $quote = $dialect === 'Mysql' ? '`' : '"';
+ return $quote . $prefix . $name . $quote;
+ }
+ function rowCount(string $name): int
+ {
+ return (int)Db::get()->fetchRow('SELECT COUNT(*) AS n FROM ' . table($name))['n'];
+ }
+ function fields(int $cid): array
+ {
+ return Db::get()->fetchAll(Db::get()->select()->from('table.fields')->where('cid = ?', $cid)->order('name', Db::SORT_ASC));
+ }
+ function post(array $data): int
+ {
+ return (int)Database::query(Db::get()->insert('table.contents')->rows($data + [
+ 'title' => 'Fixture post', 'slug' => bin2hex(random_bytes(10)), 'created' => 100,
+ 'modified' => 100, 'authorId' => 7, 'type' => 'post', 'status' => 'draft',
+ ]));
+ }
+ function photo(int $cid, array $data = [], int $sort = 0): int
+ {
+ return ImageRepository::insertRow($cid, $data + [
+ 'original' => null, 'full' => '/usr/uploads/full/a.webp', 'thumb' => '/usr/uploads/thumb/a.webp',
+ 'width' => 1200, 'height' => 600, 'size' => 1000,
+ 'title' => 'photo', 'desc' => 'description', 'address' => 'Location',
+ 'exif' => ['make' => 'Camera', 'datetime' => '2024:05:06 10:20:30', 'gps' => ['lat' => 1]],
+ ], $sort);
+ }
+ // Real database errors, after earlier writes in the same operation have succeeded.
+ function failureTrigger(bool $enabled, string $field = 'tags'): void
+ {
+ global $db, $prefix, $dialect;
+ $name = $prefix . 'reject_field';
+ $fn = $prefix . 'reject_field_fn';
+ if (!$enabled) {
+ Database::query('DROP TRIGGER IF EXISTS ' . $name . ($dialect === 'Pgsql' ? ' ON ' . table('fields') : ''), Db::WRITE);
+ if ($dialect === 'Pgsql') { Database::query('DROP FUNCTION IF EXISTS ' . $fn . '()', Db::WRITE); }
+ return;
+ }
+ if (!in_array($field, ['tags', 'titles'], true)) { throw new \InvalidArgumentException('Invalid trigger field'); }
+ if ($dialect === 'SQLite') {
+ $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields')
+ . " WHEN NEW.name = '$field' BEGIN SELECT RAISE(ABORT, 'Integration failure'); END";
+ } elseif ($dialect === 'Mysql') {
+ $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields')
+ . " FOR EACH ROW BEGIN IF NEW.name = '$field' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration failure'; END IF; END";
+ } else {
+ Database::query('CREATE FUNCTION ' . $fn . "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.name = '$field' THEN RAISE EXCEPTION 'Integration failure'; END IF; RETURN NEW; END $$", Db::WRITE);
+ $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields')
+ . ' FOR EACH ROW EXECUTE PROCEDURE ' . $fn . '()';
+ }
+ Database::query($sql, Db::WRITE);
+ }
+
+ $failed = null;
+ try {
+ // Use the exact upstream schema, including unique keys and quoted camel-case columns.
+ $schema = file_get_contents($typecho . '/install/' . $dialect . '.sql');
+ if ($schema === false) { throw new \RuntimeException('Missing upstream install schema'); }
+ $schema = preg_replace('/^\s*--[^\n]*(?:\n|$)/m', '', $schema);
+ $schema = str_replace(['typecho_', '%engine%', '%charset%'], [$prefix, 'InnoDB', 'utf8mb4'], $schema);
+ foreach (explode(';', $schema) as $statement) {
+ if (trim($statement) !== '') { Database::query($statement, Db::WRITE); }
+ }
+ // Avoid activation's filesystem/tool/menu side effects, but execute production DDL.
+ $create = new \ReflectionMethod(Plugin::class, 'createTables');
+ $create->setAccessible(true);
+ $create->invoke(null);
+ check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, 'Fresh schema migration must finish');
+ check(rowCount('infinitytime_images') === 0, 'Fresh schema must be empty');
+ $create->invoke(null);
+ check(rowCount('options') === 1, 'Repeated setup must not duplicate schema options');
+ check($db->selectDb(Db::READ) !== $db->selectDb(Db::WRITE), 'Test must exercise separate Typecho read/write connections');
+
+ $resource = Database::query('SELECT 1 AS probe');
+ check(Database::query($resource) === $resource, '已执行的真实查询资源必须原样返回');
+ unset($resource);
+
+ // Query placeholders are deferred by real Typecho. Include punctuation and Unicode.
+ $text = "图集 ? 50%_! O'Reilly \\ #param:42#";
+ $draft = AdminWorkflow::draft(7, ['title' => $text, 'device' => 'Camera ? 日本', 'tags' => 'night', 'address' => '杭州'], 'draft-operation-0001');
+ $cid = (int)$draft['cid'];
+ check($cid > 0 && $draft['status'] === 'draft' && !$draft['replayed'], 'Create a persisted draft');
+ check(AdminRepository::album($cid, 7, false)['title'] === $text, 'Real adapters must preserve quoted and Unicode values');
+ check(AdminRepository::field($cid, 'device') === 'Camera ? 日本', 'Draft metadata persisted');
+ check(AdminRepository::field($cid, AdminRepository::MARKER) === '1', 'Empty draft has album marker');
+ $again = AdminWorkflow::draft(7, ['title' => 'Do not overwrite'], 'draft-operation-0001');
+ check((int)$again['cid'] === $cid && $again['replayed'], 'Retry returns the existing draft');
+ check(rowCount('contents') === 1, 'Retry must not create duplicate posts');
+ $other = AdminWorkflow::draft(8, ['title' => 'Other author'], 'draft-operation-0001');
+ check((int)$other['cid'] !== $cid, 'Operation keys are scoped to their author');
+ check(AdminRepository::album($cid, 8, false) === null, 'Foreign author cannot access an album');
+ check(AdminRepository::album($cid, 8, true) !== null, 'Administrator can access foreign album');
+ check(AdminRepository::album($cid, 0, true) === null, 'Missing user identity is rejected');
+ rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 8, false); }, \DomainException::class, 'Mutation refuses foreign album');
+ rejected(static function () { AdminWorkflow::draft(7, ['title' => 'bad'], 'short'); }, \InvalidArgumentException::class, 'Invalid operation key rejected');
+ rejected(static function () { AdminWorkflow::draft(7, ['title' => ' '], 'draft-operation-0002'); }, \InvalidArgumentException::class, 'Blank title rejected');
+ check(AdminSecurity::validMutation('POST', 'token', 'token') && !AdminSecurity::validMutation('GET', 'token', 'token')
+ && !AdminSecurity::validMutation('POST', '', '') && !AdminSecurity::validMutation('POST', 'token', ['token']), 'Mutation guard fails closed');
+
+ $ordinary = post([]);
+ check(AdminRepository::album($ordinary, 7, false) === null, 'Ordinary post is not an album');
+ foreach (['attachment', 'page'] as $type) {
+ $excluded = post(['type' => $type]);
+ AdminRepository::setField($excluded, AdminRepository::MARKER, '1');
+ check(AdminRepository::album($excluded, 7, true) === null, 'Wrong content type rejected: ' . $type);
+ }
+ $hidden = post(['status' => 'private']);
+ AdminRepository::setField($hidden, AdminRepository::MARKER, '1');
+ check(AdminRepository::album($hidden, 7, true) === null, 'Unsupported post status rejected');
+ rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, true); }, \InvalidArgumentException::class, 'Empty draft cannot publish');
+ check(AdminRepository::page(7, false, 1, '50%_!')['total'] === 1, 'LIKE wildcard characters searched literally');
+ check(AdminRepository::page(7, false, 1, "' OR 1=1 --")['total'] === 0, 'Search cannot inject SQL');
+ check(AdminRepository::page(8, false)['total'] === 1, 'Pagination respects author scope');
+
+ // A Query passed to Db::query(..., WRITE) is silently rerouted to READ by Typecho.
+ // Production helpers must compile it to a string before choosing the writer.
+ AdminRepository::setField($cid, 'device', 'before transaction');
+ Database::query('BEGIN', Db::WRITE);
+ try {
+ AdminRepository::setField($cid, 'device', 'inside transaction');
+ check(AdminRepository::field($cid, 'device', true) === 'inside transaction', 'Writer sees uncommitted metadata');
+ check(AdminRepository::field($cid, 'device') === 'before transaction', 'Reader remains isolated from writer');
+ $query = $db->select('str_value')->from('table.fields')->where('cid = ?', $cid)->where('name = ?', 'device');
+ check(AdminRepository::readAll($query, true)[0]['str_value'] === 'inside transaction', 'readAll compiles and routes to writer');
+ } finally { Database::query('ROLLBACK', Db::WRITE); }
+ check(AdminRepository::field($cid, 'device') === 'before transaction', 'Rollback restores existing field');
+
+ $before = rowCount('contents');
+ failureTrigger(true);
+ try {
+ injectedFailure(static function () { AdminWorkflow::draft(7, ['title' => 'Rollback draft', 'device' => 'first write', 'tags' => 'fail'], 'draft-operation-fail'); },
+ 'Draft field failure surfaces database error');
+ } finally { failureTrigger(false); }
+ check(rowCount('contents') === $before, 'Draft and earlier fields rollback together');
+ check(AdminRepository::draftForOperation(7, 'draft-operation-fail') === null, 'Failed draft leaves no operation marker');
+
+ $a = photo($cid, ['full' => '/usr/uploads/full/late.webp', 'title' => 'Late', 'upload_key' => 'photo-operation-0001'], 20);
+ $b = photo($cid, ['full' => '/usr/uploads/full/early.webp', 'title' => "Early ' ?", 'desc' => 'Unicode 雨', 'width' => 800, 'height' => 600], 10);
+ check($a > 0 && $b > $a, 'Image inserts return real generated primary keys');
+ injectedFailure(static function () use ($cid) { photo($cid, ['upload_key' => 'photo-operation-0001']); },
+ 'Same-album upload retry key is unique', $dialect === 'SQLite' ? 'UNIQUE' : 'duplicate');
+ check(rowCount('infinitytime_images') === 2, 'Rejected duplicate must not add a row');
+ check(photo((int)$other['cid'], ['upload_key' => 'photo-operation-0001']) > 0, 'Upload keys can repeat in different albums');
+ check(photo((int)$other['cid']) > 0 && photo((int)$other['cid']) > 0, 'Multiple legacy NULL upload keys allowed');
+ ImageRepository::syncPostFields($cid);
+ check(AdminRepository::field($cid, 'img') === "/usr/uploads/full/early.webp\n/usr/uploads/full/late.webp", 'Image paths follow sort order');
+ check(json_decode(AdminRepository::field($cid, 'photo_ids'), true) === [$b, $a], 'Photo IDs aligned with paths');
+ check(json_decode(AdminRepository::field($cid, 'titles'), true) === ["Early ' ?", 'Late'], 'Titles aligned with paths');
+ check(json_decode(AdminRepository::field($cid, 'dims'), true) === ['800x600', '1200x600'], 'Dimensions aligned with paths');
+ check(json_decode(AdminRepository::field($cid, 'panos'), true) === [0, 1], 'Panorama metadata aligned');
+ $exif = json_decode(AdminRepository::field($cid, 'exif'), true);
+ check(!isset($exif[0]['gps']) && !isset($exif[1]['gps']), 'Public EXIF fields omit GPS');
+ check(AdminRepository::counts([$cid, $cid, 0, -1]) === [$cid => 2], 'Managed image counts deduplicate IDs');
+ check(count(ImageRepository::rowsForCids([$cid, (int)$other['cid']])) === 2, 'Batch image lookup groups albums');
+ $legacy = post(['title' => 'Legacy album']);
+ AdminRepository::setField($legacy, 'img', "/legacy/a.webp\n/legacy/b.webp");
+ ImageRepository::syncPostFields($legacy);
+ check(AdminRepository::counts([$legacy]) === [$legacy => 2], 'Legacy line-count syntax is portable');
+ check(AdminRepository::album($legacy, 7, false) !== null, 'Legacy fields identify an album');
+ check(AdminRepository::field($legacy, 'img') === "/legacy/a.webp\n/legacy/b.webp", 'Sync preserves legacy-only images');
+
+ // Atomic replacement: fail after img/thumb have already been rewritten.
+ $snapshot = fields($cid);
+ Database::query($db->update(ImageRepository::table())->rows(['title' => 'Changed'])->where('id = ?', $b));
+ failureTrigger(true, 'titles');
+ try {
+ injectedFailure(static function () use ($cid) { ImageRepository::syncPostFields($cid); },
+ 'Field-sync failure surfaces database error');
+ } finally { failureTrigger(false); }
+ check(fields($cid) === $snapshot, 'Failed field sync must retain the entire old snapshot');
+ ImageRepository::syncPostFields($cid);
+ check(json_decode(AdminRepository::field($cid, 'titles'), true)[0] === 'Changed', 'Retry successfully updates all fields');
+ check(json_decode(AdminRepository::field($cid, 'months'), true) === ['2024-05', '2024-05'], 'EXIF dates are kept aligned');
+
+ // Caller-owned transaction: sync must neither commit nor discard unrelated work.
+ $outerSnapshot = fields($cid);
+ Database::query('BEGIN', Db::WRITE);
+ try {
+ AdminRepository::setField($cid, 'device', 'outer successful sync');
+ Database::query($db->update(ImageRepository::table())->rows(['title' => 'Uncommitted image'])->where('id = ?', $b));
+ ImageRepository::syncPostFields($cid);
+ check(json_decode(AdminRepository::field($cid, 'titles', true), true)[0] === 'Uncommitted image', 'Nested sync reads uncommitted writer image rows');
+ check(AdminRepository::field($cid, 'device') === 'before transaction', 'Nested sync must not commit caller writes');
+ } finally { Database::query('ROLLBACK', Db::WRITE); }
+ check(fields($cid) === $outerSnapshot, 'Caller rollback restores successful nested field sync');
+
+ failureTrigger(true, 'titles');
+ Database::query('BEGIN', Db::WRITE);
+ try {
+ AdminRepository::setField($cid, 'device', 'outer survives failure');
+ injectedFailure(static function () use ($cid) { ImageRepository::syncPostFields($cid); }, 'Nested sync reports injected error');
+ check(AdminRepository::field($cid, 'device', true) === 'outer survives failure', 'Failed nested sync preserves earlier caller writes');
+ check(json_decode(AdminRepository::field($cid, 'titles', true), true)[0] === 'Changed', 'Failed nested sync restores old title within caller transaction');
+ AdminRepository::setField($cid, 'device', 'outer remains usable');
+ check(AdminRepository::field($cid, 'device', true) === 'outer remains usable', 'Caller can continue using transaction after failed sync');
+ } finally {
+ Database::query('ROLLBACK', Db::WRITE);
+ failureTrigger(false);
+ }
+ check(fields($cid) === $outerSnapshot, 'Caller rollback after failed sync restores all fields');
+
+ rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, false); }, \DomainException::class, 'Contributor cannot publish');
+ AdminWorkflow::publish($cid, 7, false, true);
+ check(AdminRepository::album($cid, 7, false)['status'] === 'publish', 'Editor publishes nonempty album');
+ rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 7, false, false); }, \DomainException::class, 'Contributor cannot modify published album');
+ check(AdminWorkflow::requireAlbum($cid, 7, false, true)['status'] === 'publish', 'Editor can modify own published album');
+
+ // 模拟真正滞后的副本:读库完全没有主库的图集、图片和自定义字段。
+ $primaryDb = $db;
+ $primaryConfig = $primaryDb->getConfig(Db::WRITE)->toArray();
+ $replicaConfig = $primaryConfig;
+ $replicaName = $prefix . 'replica';
+ $replicaDb = null;
+ $splitDb = null;
+ $replicaCreated = false;
+ try {
+ if ($dialect === 'SQLite') {
+ $replicaConfig['file'] = $tmp . '/replica.sqlite';
+ } else {
+ Database::query('CREATE DATABASE ' . ($dialect === 'Mysql' ? '`' : '"') . $replicaName . ($dialect === 'Mysql' ? '`' : '"'));
+ $replicaConfig['database'] = $replicaName;
+ $replicaCreated = true;
+ }
+ $replicaDb = new Db($adapter, $prefix);
+ $replicaDb->addServer($replicaConfig, Db::READ | Db::WRITE);
+ Db::set($replicaDb);
+ foreach (explode(';', $schema) as $statement) {
+ if (trim($statement) !== '') { Database::query($statement); }
+ }
+ $create->invoke(null);
+ $splitDb = new Db($adapter, $prefix);
+ $splitDb->addServer($primaryConfig, Db::WRITE);
+ $splitDb->addServer($replicaConfig, Db::READ);
+ Db::set($splitDb);
+ // 先连接主库,再连接副本,复现官方 Mysqli 的内部连接被后一次 connect 覆盖。
+ $splitDb->selectDb(Db::WRITE);
+ check(AdminRepository::page(7, true)['total'] === 0, '滞后副本确实不含主库图集');
+ check(AdminRepository::album($cid, 7, false) === null, '普通读取仍使用副本');
+ check(AdminWorkflow::requireAlbum($cid, 7, false, true)['status'] === 'publish', '写权限检查必须读取主库');
+ check(AdminRepository::field($cid, 'device', true) === 'before transaction', '字段写前检查必须读取主库');
+ check((int)AdminRepository::draftForOperation(7, 'draft-operation-0001')['cid'] === $cid, '草稿重试必须读取主库');
+ check(count(ImageRepository::rowsFor($cid)) === 2, '持锁图片读取必须使用主库');
+ check(count(ImageRepository::rowsForCids([$cid])[$cid]) === 2, '持锁批量图片读取必须使用主库');
+ check(AdminRepository::counts([$cid], true) === [$cid => 2], '发布计数必须读取主库');
+ $isolatedDraft = AdminWorkflow::draft(7, ['title' => '主库新建草稿'], 'draft-primary-only');
+ $newCid = (int)$isolatedDraft['cid'];
+ check($newCid > 0 && AdminRepository::album($newCid, 7, false) === null, '新增草稿返回主库 ID,副本仍为空');
+ check(AdminRepository::album($newCid, 7, false, true) !== null, '新增草稿确实写入主库');
+ $newPhoto = photo($newCid, ['title' => '主库新图片']);
+ check($newPhoto > 0 && count(ImageRepository::rowsFor($newCid)) === 1, '图片入库返回主库 ID');
+ ImageRepository::syncPostFields($newCid);
+ check(AdminRepository::field($newCid, 'img', true) === '/usr/uploads/full/a.webp', '字段同步读取并写入主库');
+ check(AdminRepository::field($newCid, 'img') === '', '字段同步没有污染副本');
+ check(Database::query($splitDb->update(ImageRepository::table())->rows(['title' => '修改主库图片'])->where('id = ?', $newPhoto)) === 1, '更新返回主库实际影响行数');
+ check(Database::query($splitDb->delete(ImageRepository::table())->where('id = ?', $newPhoto)) === 1, '删除返回主库实际影响行数');
+ check(count(ImageRepository::rowsFor($newCid)) === 0, '删除确实发生在主库');
+ } finally {
+ Db::set($primaryDb);
+ foreach ([$splitDb, $replicaDb] as $connection) {
+ if ($connection !== null) {
+ // native pg_connect 会缓存连接,显式断开后才允许 DROP DATABASE。
+ if ($adapter === 'Pgsql') {
+ foreach ([Db::READ, Db::WRITE] as $operation) {
+ try {
+ $handle = $connection->selectDb($operation);
+ if ($handle !== $primaryDb->selectDb(Db::WRITE) && $handle !== $primaryDb->selectDb(Db::READ)) { pg_close($handle); }
+ } catch (\Throwable $ignored) {}
+ }
+ }
+ $connection->flushPool();
+ }
+ }
+ unset($connection, $splitDb, $replicaDb);
+ if ($replicaCreated) {
+ Database::query('DROP DATABASE ' . ($dialect === 'Mysql' ? '`' : '"') . $replicaName . ($dialect === 'Mysql' ? '`' : '"'));
+ }
+ foreach (glob($tmp . '/replica.sqlite*') ?: [] as $file) { @unlink($file); }
+ }
+
+ Database::query($db->delete(ImageRepository::table())->where('cid = ?', $cid));
+ ImageRepository::syncPostFields($cid, true);
+ check(AdminRepository::field($cid, 'img') === '' && AdminRepository::field($cid, 'photo_ids') === '', 'Explicit clear removes last-image metadata');
+ check(AdminRepository::album($cid, 7, false) !== null, 'Cleared album keeps its marker');
+
+ // Genuine old-install migration: absent metadata/retry columns, existing rows preserved.
+ Database::query('DROP TABLE ' . table('infinitytime_images'), Db::WRITE);
+ $pk = $dialect === 'Mysql' ? 'id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY'
+ : ($dialect === 'Pgsql' ? 'id SERIAL PRIMARY KEY' : 'id INTEGER PRIMARY KEY AUTOINCREMENT');
+ Database::query('CREATE TABLE ' . table('infinitytime_images') . ' (' . $pk . ', cid INTEGER, ' . ($dialect === 'Mysql' ? '`full`' : '"full"') . ' TEXT)', Db::WRITE);
+ Database::query($db->insert(ImageRepository::table())->rows(['cid' => 1, 'full' => '/preserved.webp']));
+ Plugin::setOption('infinitytimeSchemaVersion', '2');
+ Plugin::migrateSchema();
+ $preserved = $db->fetchRow($db->select()->from(ImageRepository::table()));
+ check($preserved['full'] === '/preserved.webp' && $preserved['upload_key'] === null, 'Migration preserves existing rows with nullable retry keys');
+ check(array_key_exists('mid_avif', $preserved) && array_key_exists('desc', $preserved), 'Migration repairs every historical missing column');
+ Plugin::setOption('infinitytimeSchemaVersion', '3');
+ Plugin::migrateSchema();
+ check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, 'Interrupted migration validates existing unique index');
+ check(rowCount('infinitytime_images') === 1, 'Repeated migration never duplicates images');
+ echo 'PASS: ' . $checks . ' real Typecho ' . \Typecho\Common::VERSION . ' / PHP ' . PHP_VERSION . ' / ' . $adapter . " checks\n";
+ } catch (\Throwable $e) {
+ $failed = $e;
+ } finally {
+ ImageRepository::unlockMedia();
+ // Only our random-prefixed objects are removed, including after a failing assertion.
+ foreach (['infinitytime_images', 'fields', 'contents', 'comments', 'metas', 'options', 'relationships', 'users'] as $name) {
+ try { Database::query('DROP TABLE IF EXISTS ' . table($name) . ($dialect === 'Pgsql' ? ' CASCADE' : ''), Db::WRITE); }
+ catch (\Throwable $cleanup) { fwrite(STDERR, 'Cleanup: ' . $cleanup->getMessage() . "\n"); }
+ }
+ if ($dialect === 'Pgsql') {
+ foreach (['comments', 'contents', 'metas', 'users'] as $name) {
+ try { Database::query('DROP SEQUENCE IF EXISTS ' . table($name . '_seq'), Db::WRITE); } catch (\Throwable $cleanup) {}
+ }
+ try { Database::query('DROP FUNCTION IF EXISTS ' . $prefix . 'reject_field_fn()', Db::WRITE); } catch (\Throwable $cleanup) {}
+ }
+ $db->flushPool();
+ foreach (glob($tmp . '/database.sqlite*') ?: [] as $file) { @unlink($file); }
+ @unlink($tmp . '/uploads/.infinitytime-media.lock');
+ @rmdir($tmp . '/uploads');
+ @rmdir($tmp);
+ }
+ if ($failed) {
+ fwrite(STDERR, 'FAIL: ' . get_class($failed) . ': ' . $failed->getMessage() . "\n" . $failed->getTraceAsString() . "\n");
+ exit(1);
+ }
+}
diff --git a/tests/upload-fallback.php b/tests/upload-fallback.php
index 1d126c1..fb18fd9 100644
--- a/tests/upload-fallback.php
+++ b/tests/upload-fallback.php
@@ -9,6 +9,7 @@ public static function factory($name): object { return new \stdClass(); }
// Typecho 最小启动替身;验证钩子失败返回约定,无需数据库或上传文件。
interface Typecho_Plugin_Interface {}
require __DIR__ . '/../usr/plugins/InfinityTime/Lib/MediaProcessor.php';
+ require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php';
require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php';
if (\TypechoPlugin\InfinityTime\Plugin::uploadHandle([]) !== false) {
throw new RuntimeException('空上传应返回 false');
diff --git a/usr/plugins/InfinityTime/Lib/AdminRepository.php b/usr/plugins/InfinityTime/Lib/AdminRepository.php
index 4110c70..2c5339e 100644
--- a/usr/plugins/InfinityTime/Lib/AdminRepository.php
+++ b/usr/plugins/InfinityTime/Lib/AdminRepository.php
@@ -26,14 +26,14 @@ public static function readRow($query, bool $writer = false): array
{
$db = Db::get();
if ($writer && is_object($query) && method_exists($query, 'prepare')) { $query = $query->prepare((string)$query); }
- return $db->fetchRow($writer ? $db->query($query, Db::WRITE) : $query) ?: [];
+ return $db->fetchRow($writer ? Database::query($query) : $query) ?: [];
}
public static function readAll($query, bool $writer = false): array
{
$db = Db::get();
if ($writer && is_object($query) && method_exists($query, 'prepare')) { $query = $query->prepare((string)$query); }
- return $db->fetchAll($writer ? $db->query($query, Db::WRITE) : $query);
+ return $db->fetchAll($writer ? Database::query($query) : $query);
}
/** 所有参数均由 Typecho 适配器转义;不直接拼接用户输入。 */
@@ -138,9 +138,9 @@ public static function setField(int $cid, string $name, string $value): void
{
$db = Db::get();
$table = $db->getPrefix() . 'fields';
- $db->query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name));
+ Database::query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name));
if ($value !== '') {
- $db->query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value]));
+ Database::query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value]));
}
}
diff --git a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php
index 7a87b10..8a346da 100644
--- a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php
+++ b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php
@@ -17,9 +17,9 @@ public static function draft(int $uid, array $data, string $key): array
if ($title === '') { throw new \InvalidArgumentException('请填写图集标题'); }
$db = Db::get();
$now = time();
- $db->query('BEGIN', Db::WRITE);
+ Database::query('BEGIN');
try {
- $cid = (int)$db->query($db->insert($db->getPrefix() . 'contents')->rows([
+ $cid = (int)Database::query($db->insert($db->getPrefix() . 'contents')->rows([
'title' => $title, 'slug' => 'album-' . date('YmdHis', $now) . '-' . bin2hex(random_bytes(6)),
'created' => $now, 'modified' => $now, 'text' => '', 'authorId' => $uid,
'type' => 'post', 'status' => 'draft', 'allowComment' => '0', 'allowPing' => '0',
@@ -31,9 +31,9 @@ public static function draft(int $uid, array $data, string $key): array
foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) {
AdminRepository::setField($cid, $name, trim((string)($data[$input] ?? '')));
}
- $db->query('COMMIT', Db::WRITE);
+ Database::query('COMMIT');
} catch (\Throwable $e) {
- $db->query('ROLLBACK', Db::WRITE);
+ Database::query('ROLLBACK');
throw $e;
}
return ['cid' => $cid, 'title' => $title, 'status' => 'draft', 'replayed' => false];
@@ -136,6 +136,6 @@ public static function publish(int $cid, int $uid, bool $admin, bool $canPublish
if (($counts[$cid] ?? 0) < 1) { throw new \InvalidArgumentException('请先上传至少一张图片再发布'); }
ImageRepository::syncPostFields($cid);
$db = Db::get();
- $db->query($db->update($db->getPrefix() . 'contents')->rows(['status' => 'publish', 'modified' => time()])->where('cid = ?', $cid));
+ Database::query($db->update($db->getPrefix() . 'contents')->rows(['status' => 'publish', 'modified' => time()])->where('cid = ?', $cid));
}
}
diff --git a/usr/plugins/InfinityTime/Lib/Database.php b/usr/plugins/InfinityTime/Lib/Database.php
new file mode 100644
index 0000000..d52becc
--- /dev/null
+++ b/usr/plugins/InfinityTime/Lib/Database.php
@@ -0,0 +1,34 @@
+getAttribute('action') : null;
+ $mysqli = strcasecmp($db->getAdapterName(), 'Mysqli') === 0;
+ if (!$mysqli) {
+ // Typecho 对 SELECT Query 强制改用 READ,即使传入 WRITE 也会覆盖。
+ if ($action === 'SELECT') { $query = $query->prepare((string)$query); }
+ return $db->query($query, Db::WRITE);
+ }
+
+ // 与 Db::query 一样,已经执行的结果句柄原样返回。
+ if (!is_string($query) && !(is_object($query) && method_exists($query, 'prepare'))) { return $query; }
+
+ $handle = $db->selectDb(Db::WRITE);
+ // Typecho Mysqli::query 忽略 handle,改用最近 connect 的内部对象。
+ // 不能通过 flushPool 修复,否则会丢失调用方正在执行的事务。
+ $sql = is_string($query) ? $query : $query->prepare((string)$query);
+ $result = $handle->query($sql);
+ if ($result === false) { throw new \RuntimeException($handle->error, $handle->errno); }
+ if ($action === 'INSERT') { return $handle->insert_id; }
+ if ($action === 'UPDATE' || $action === 'DELETE') { return $handle->affected_rows; }
+ return $result;
+ }
+}
diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php
index da69080..f4574ba 100644
--- a/usr/plugins/InfinityTime/Lib/ImageRepository.php
+++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php
@@ -244,7 +244,7 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int
self::lockMedia();
$db = \Typecho\Db::get();
$table = self::table();
- return (int)$db->query($db->insert($table)->rows([
+ return (int)Database::query($db->insert($table)->rows([
'cid' => $cid,
'original' => $meta['original'],
'full' => $meta['full'],
@@ -274,7 +274,7 @@ private static function fetchRows($db, $query): array
{
if (is_resource(self::$mediaLock)) {
// Typecho 会按 Query 类型重新选择连接池;先编译绑定参数,再显式选择主库。
- $query = $db->query($query->prepare((string)$query), \Typecho\Db::WRITE);
+ $query = Database::query($query->prepare((string)$query));
}
return $db->fetchAll($query);
}
@@ -337,7 +337,7 @@ public static function ensureSchema(): void
public static function setImageMeta(int $rowId, string $title, string $desc, string $address): void
{
$db = \Typecho\Db::get();
- $db->query($db->update(self::table())->rows([
+ Database::query($db->update(self::table())->rows([
'title' => $title,
'desc' => $desc,
'address' => $address,
@@ -357,15 +357,97 @@ public static function updateVariants(int $rowId, array $result): void
foreach (['width', 'height', 'size'] as $field) {
if (isset($result[$field])) { $values[$field] = (int)$result[$field]; }
}
- $db->query($db->update(self::table())->rows($values)->where('id = ?', $rowId));
+ Database::query($db->update(self::table())->rows($values)->where('id = ?', $rowId));
}
- /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */
+ /** 主库上的原子字段替换;保存点不会提交或回滚调用方的其他写入。 */
public static function syncPostFields(int $cid, bool $clearEmpty = false): void
+ {
+ self::lockMedia();
+ $db = \Typecho\Db::get();
+ $adapter = strtolower($db->getAdapterName());
+ $handle = $db->selectDb(\Typecho\Db::WRITE);
+ $write = static function ($query) {
+ // 所有同步语句编译后固定走主库;不需要 INSERT 的生成主键。
+ return Database::query(is_string($query) ? $query : $query->prepare((string)$query));
+ };
+ $savepoint = 'infinitytime_fields_' . bin2hex(random_bytes(8));
+ $owned = false;
+ $mysql = strpos($adapter, 'mysql') !== false;
+ if ($mysql) {
+ // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。
+ $query = $db->select();
+ $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($db->getPrefix() . 'fields'));
+ $table = $db->fetchRow($write($sql));
+ if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) {
+ throw new \RuntimeException('字段同步需要 fields 表使用 InnoDB 事务引擎,请先备份并由管理员迁移表引擎');
+ }
+ // MySQL 在 autocommit 下静默忽略 SAVEPOINT;必须在写入前验证它实际存在。
+ // 不用 BEGIN 探测:它会隐式提交已有事务。也兼容调用方 SET autocommit=0。
+ $write('SAVEPOINT ' . $savepoint);
+ try {
+ // 直接检查驱动错误码,避免 Typecho PDO 包装丢失 MySQL errno。
+ $result = $handle->query('ROLLBACK TO SAVEPOINT ' . $savepoint);
+ if ($result === false) {
+ $code = $handle instanceof \PDO ? (int)($handle->errorInfo()[1] ?? 0) : (int)$handle->errno;
+ throw new \RuntimeException('无法验证字段同步保存点', $code);
+ }
+ } catch (\Throwable $e) {
+ $code = $e instanceof \PDOException ? (int)($e->errorInfo[1] ?? 0) : (int)$e->getCode();
+ if ($code !== 1305) { throw $e; }
+ $write('BEGIN');
+ $owned = true;
+ }
+ } elseif (strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false) {
+ if ($handle instanceof \PDO) {
+ $active = $handle->inTransaction();
+ } elseif (function_exists('pg_transaction_status')) {
+ $status = pg_transaction_status($handle);
+ if (!in_array($status, [PGSQL_TRANSACTION_IDLE, PGSQL_TRANSACTION_INTRANS], true)) {
+ throw new \RuntimeException('数据库事务不可用,请先结束当前失败的事务');
+ }
+ $active = $status === PGSQL_TRANSACTION_INTRANS;
+ } else {
+ throw new \RuntimeException('无法安全检测数据库事务状态');
+ }
+ if (!$active) { $write('BEGIN'); $owned = true; }
+ } elseif (strpos($adapter, 'sqlite') === false) {
+ throw new \RuntimeException('当前数据库适配器不支持安全的字段同步事务');
+ }
+ // SQLite SAVEPOINT 可独立启动事务;嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT。
+ $started = false;
+ try {
+ $write('SAVEPOINT ' . $savepoint);
+ $started = true;
+ self::writePostFields($cid, $clearEmpty, $write);
+ $write('RELEASE SAVEPOINT ' . $savepoint);
+ if ($owned) { $write('COMMIT'); }
+ } catch (\Throwable $e) {
+ try {
+ if ($owned) { $write('ROLLBACK'); }
+ elseif ($started) {
+ $write('ROLLBACK TO SAVEPOINT ' . $savepoint);
+ $write('RELEASE SAVEPOINT ' . $savepoint);
+ }
+ } catch (\Throwable $rollbackError) {
+ // 保留最初的错误,同时明确记录连接丢失等导致无法确认回滚的情况。
+ Plugin::log('field sync rollback failed: ' . $rollbackError->getMessage());
+ }
+ throw $e;
+ }
+ }
+
+ /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */
+ private static function writePostFields(int $cid, bool $clearEmpty, callable $write): void
{
$db = \Typecho\Db::get();
$prefix = $db->getPrefix();
- $rows = self::rowsFor($cid);
+ // 不在调用方可能已开启的事务内运行 schema 迁移(MySQL DDL 会隐式提交)。
+ $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); }
+ unset($row);
// 历史文章可能仅有自定义字段。无图片行时不覆盖,删除最后一张图则显式清空。
if (!$rows && !$clearEmpty) {
return;
@@ -408,15 +490,15 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void
$exifs[] = $e;
}
foreach (['img' => $images, 'thumb' => $thumbs] as $name => $paths) {
- $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name));
+ $write($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name));
// 保留空 img 标记,使删除最后一张图片后的空图集仍可识别。
- $db->query($db->insert($prefix . 'fields')->rows([
+ $write($db->insert($prefix . 'fields')->rows([
'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => implode("\n", $paths),
]));
}
$map = ['addresses' => $addresses, 'titles' => $titles, 'descs' => $descs, 'panos' => $panos, 'dims' => $dims, 'variants' => $variants, 'exif' => $exifs, 'photo_ids' => $photoIds, 'months' => $months];
foreach (['addresses', 'titles', 'descs', 'panos', 'dims', 'variants', 'exif', 'photo_ids', 'months'] as $f) {
- $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $f));
+ $write($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $f));
$val = $map[$f];
// 注意:variants 的元素是数组,不能直接用 array_filter($val, 'strlen')(PHP 8 会对数组调 strlen 报错)
$nonEmpty = false;
@@ -427,9 +509,9 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void
}
}
if ($nonEmpty) {
- $db->query($db->insert($prefix . 'fields')->rows([
+ $write($db->insert($prefix . 'fields')->rows([
'cid' => $cid, 'name' => $f, 'type' => 'str',
- 'str_value' => json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'str_value' => json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]));
}
}
@@ -454,7 +536,7 @@ public static function removeFor(int $cid): void
self::unlinkFiles($r['original'], $r['full'], $r['mid'] ?? null, $r['avif'] ?? null, $r['mid_avif'] ?? null, $r['thumb']);
}
$db = \Typecho\Db::get();
- $db->query($db->delete(self::table())->where('cid = ?', $cid));
+ Database::query($db->delete(self::table())->where('cid = ?', $cid));
}
/** 仅处理真实上传目录内的普通文件;旧快照、软链接与新文件一律跳过。 */
@@ -492,7 +574,8 @@ private static function referencedPaths(): array
$real = realpath($path);
if ($real !== false) { $referenced[$real] = true; }
};
- foreach (self::fetchRows($db, $db->select()->from(self::table())) as $row) {
+ // 只读引用路径;扫描孤儿文件不需要搬运可能很大的 EXIF/标题等元数据。
+ foreach (self::fetchRows($db, $db->select('original', 'full', 'thumb', 'mid', 'avif', 'mid_avif')->from(self::table())) as $row) {
foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $field) {
$add($row[$field] ?? null);
}
diff --git a/usr/plugins/InfinityTime/Lib/MaintenanceState.php b/usr/plugins/InfinityTime/Lib/MaintenanceState.php
new file mode 100644
index 0000000..db355f5
--- /dev/null
+++ b/usr/plugins/InfinityTime/Lib/MaintenanceState.php
@@ -0,0 +1,80 @@
+ $job, 'job_id' => bin2hex(random_bytes(16)), 'total' => count($list),
+ 'done' => 0, 'current' => '', 'failed' => 0, 'finished' => false];
+ self::write(self::listFile($dir, $state), $list);
+ self::write($file, $state);
+ return [$state, $list];
+ }
+
+ 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('无效的维护任务状态');
+ }
+ return $dir . '/' . $job . '_' . $id . '_list.json';
+ }
+
+ 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 daf8905..49e6bdb 100644
--- a/usr/plugins/InfinityTime/Plugin.php
+++ b/usr/plugins/InfinityTime/Plugin.php
@@ -4,7 +4,7 @@
*
* @package InfinityTime
* @author InfinityTime
- * @version 1.14.0
+ * @version 1.14.1
* @link https://github.com/infinitytime/infinitytime
*/
@@ -15,6 +15,7 @@
use Typecho\Widget\Helper\Form\Element\Number;
use Typecho\Widget\Helper\Form\Element\Radio;
use Typecho\Widget\Helper\Form\Element\Text;
+use TypechoPlugin\InfinityTime\Lib\Database;
use TypechoPlugin\InfinityTime\Lib\ImageRepository;
use TypechoPlugin\InfinityTime\Lib\MediaProcessor;
@@ -26,7 +27,7 @@
// Typecho versions instead of being misclassified as an instant plugin.
class Plugin implements \Typecho_Plugin_Interface
{
- public const VERSION = '1.14.0';
+ public const VERSION = '1.14.1';
public const MENU_NAME = 'InfinityTime';
// 统一默认(“恢复默认/写入”)配置,避免各处写死不同数值
public const DEFAULT_QUALITY = 76; // 缩略图质量
@@ -385,7 +386,7 @@ private static function createTables(): void
if ($isMysql) {
$sql .= " engine=InnoDB DEFAULT CHARSET=utf8mb4";
}
- $db->query($sql, Db::WRITE);
+ Database::query($sql);
// 旧表升级:按 schema 版本补齐缺失列
self::migrateSchema();
@@ -393,9 +394,9 @@ private static function createTables(): void
// 索引(跨库兼容:SQLite 支持 IF NOT EXISTS;MySQL/MariaDB 需 try/catch 忽略已存在)
try {
if ($isMysql) {
- $db->query("CREATE INDEX idx_{$table}_cid ON {$q}{$table}{$q} (`cid`)", Db::WRITE);
+ Database::query("CREATE INDEX idx_{$table}_cid ON {$q}{$table}{$q} (`cid`)");
} else {
- $db->query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})", Db::WRITE);
+ Database::query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})");
}
} catch (\Throwable $e) {
// 已存在或不受支持时忽略
@@ -423,18 +424,18 @@ public static function migrateSchema(): void
// 使用限定列名,避免 SQLite 将不存在的双引号列名当作字符串。
$probe = "SELECT {$q}{$table}{$q}.{$q}{$col}{$q} FROM {$q}{$table}{$q} WHERE 1 = 0";
try {
- $db->query($probe, Db::WRITE);
+ Database::query($probe);
continue;
} catch (\Throwable $e) {
// 缺列时尝试补齐;其他读取错误也不能冒充成功。
}
try {
- $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}", Db::WRITE);
+ Database::query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}");
} catch (\Throwable $e) {
// 另一进程可能已加列;下面实际探测成功才允许继续。
}
try {
- $db->query($probe, Db::WRITE);
+ Database::query($probe);
} catch (\Throwable $e) {
throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e);
}
@@ -452,26 +453,26 @@ private static function ensureUploadIndex($db, string $table): void
$q = $mysql ? '`' : '"';
$index = 'it_upload_' . substr(sha1($table), 0, 16);
try {
- $db->query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})", Db::WRITE);
+ Database::query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})");
return;
} catch (\Throwable $e) {
// 建索引失败时,只有验证已存在的索引确为目标唯一索引才允许继续。
}
$valid = false;
if ($mysql) {
- $rows = $db->fetchAll($db->query("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'", Db::WRITE));
+ $rows = $db->fetchAll(Database::query("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'"));
usort($rows, static function ($a, $b) { return (int)$a['Seq_in_index'] <=> (int)$b['Seq_in_index']; });
$valid = count($rows) === 2 && (int)$rows[0]['Non_unique'] === 0 && (int)$rows[1]['Non_unique'] === 0
&& array_column($rows, 'Column_name') === ['cid', 'upload_key'];
} elseif ($pgsql) {
- $rows = $db->fetchAll($db->query("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'", Db::WRITE));
+ $rows = $db->fetchAll(Database::query("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'"));
$definition = str_replace('"', '', (string)($rows[0]['indexdef'] ?? ''));
$valid = strpos($definition, 'CREATE UNIQUE INDEX ') === 0
&& preg_match('/\(cid,\s*upload_key\)/', $definition) === 1;
} else {
- foreach ($db->fetchAll($db->query("PRAGMA index_list(" . $q . $table . $q . ")", Db::WRITE)) as $row) {
+ foreach ($db->fetchAll(Database::query("PRAGMA index_list(" . $q . $table . $q . ")")) as $row) {
if (($row['name'] ?? '') !== $index || (int)($row['unique'] ?? 0) !== 1) { continue; }
- $columns = $db->fetchAll($db->query("PRAGMA index_info(" . $q . $index . $q . ")", Db::WRITE));
+ $columns = $db->fetchAll(Database::query("PRAGMA index_info(" . $q . $index . $q . ")"));
$valid = array_column($columns, 'name') === ['cid', 'upload_key'];
}
}
@@ -504,11 +505,11 @@ public static function setOption(string $name, $value): void
$prefix = $db->getPrefix();
$val = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)$value;
$query = $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1);
- $exists = $db->fetchRow($db->query($query->prepare((string)$query), Db::WRITE));
+ $exists = $db->fetchRow(Database::query($query->prepare((string)$query)));
if ($exists) {
- $db->query($db->update($prefix . 'options')->rows(['value' => $val])->where('name = ?', $name));
+ Database::query($db->update($prefix . 'options')->rows(['value' => $val])->where('name = ?', $name));
} else {
- $db->query($db->insert($prefix . 'options')->rows(['name' => $name, 'user' => 0, 'value' => $val]));
+ Database::query($db->insert($prefix . 'options')->rows(['name' => $name, 'user' => 0, 'value' => $val]));
}
$options = Helper::options();
$options->{$name} = $value;
diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js
index 954fb52..39d6f52 100644
--- a/usr/plugins/InfinityTime/assets/admin.js
+++ b/usr/plugins/InfinityTime/assets/admin.js
@@ -564,7 +564,7 @@
/* ---------- 维护任务 ---------- */
var runningJobs = {};
- function runJob(job) {
+ function runJob(job, resume) {
if (runningJobs[job]) return;
runningJobs[job] = true;
var bar = $('#pp-bar-' + job);
@@ -573,11 +573,15 @@
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 || '') : '';
function tick() {
- post('maintenance', { job: job })
+ 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; }
+ jobId = d.job_id || jobId;
+ mode = 'poll';
var pct = d.total ? Math.round(d.done * 100 / d.total) : 100;
if (bar) bar.style.width = pct + '%';
if (d.total > 0) {
@@ -587,7 +591,7 @@
if (msg) msg.textContent = noJob[job] || '没有需要处理的项目';
}
if (!d.finished) { setTimeout(tick, 300); return; }
- if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 张失败,请查看日志)') : ' ✓ 完成';
+ if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 项失败,请查看日志)') : ' ✓ 完成';
if (btn) btn.disabled = false;
runningJobs[job] = false;
})
@@ -757,7 +761,7 @@
if (container) container.insertBefore(tip, container.firstElementChild);
tip.querySelector('button').addEventListener('click', function () {
tip.parentNode.removeChild(tip);
- runJob(JOB.job);
+ runJob(JOB.job, true);
});
}
});
diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php
index 10985d3..3e36a78 100644
--- a/usr/plugins/InfinityTime/panel.php
+++ b/usr/plugins/InfinityTime/panel.php
@@ -16,6 +16,9 @@
use TypechoPlugin\InfinityTime\Lib\AdminRepository;
use TypechoPlugin\InfinityTime\Lib\AdminSecurity;
use TypechoPlugin\InfinityTime\Lib\AdminWorkflow;
+use TypechoPlugin\InfinityTime\Lib\MaintenanceState;
+use TypechoPlugin\InfinityTime\Lib\Database;
+require_once __DIR__ . '/Lib/MaintenanceState.php';
$db = Db::get();
$user = User::alloc();
@@ -193,9 +196,9 @@ function pp_set_field(int $cid, string $name, string $value): void
{
$db = Db::get();
$prefix = $db->getPrefix();
- $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name));
+ Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name));
if ($value !== '') {
- $db->query($db->insert($prefix . 'fields')->rows([
+ Database::query($db->insert($prefix . 'fields')->rows([
'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value,
]));
}
@@ -212,15 +215,12 @@ function pp_data_file(): string
function pp_read_json(string $file): array
{
- return is_file($file) ? (json_decode((string)@file_get_contents($file), true) ?: []) : [];
+ return MaintenanceState::read($file);
}
function pp_write_json(string $file, array $data): void
{
- $tmp = $file . '.tmp.' . getmypid();
- if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE)) !== false) {
- @rename($tmp, $file);
- }
+ MaintenanceState::write($file, $data);
}
/** 清理 original/full/thumb 下因删除文件而空出的目录(自底向上)。 */
@@ -303,33 +303,24 @@ function pp_exif_summary(array $exif): string
$__uid = (int)($user->uid ?? 0);
$__lockFile = pp_data_file() . '/job.lock';
$__lock = is_file($__lockFile) ? (json_decode((string)@file_get_contents($__lockFile), true) ?: []) : [];
- if ($__lock && (time() - (int)($__lock['time'] ?? 0)) < 90 && (int)($__lock['uid'] ?? 0) !== $__uid) {
+ if ($__lock && (time() - (int)($__lock['time'] ?? 0)) < 90 && ((int)($__lock['uid'] ?? 0) !== $__uid || (string)($__lock['job'] ?? '') !== $job)) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '另一个维护任务正在进行,请稍后再试'], JSON_UNESCAPED_UNICODE);
exit;
}
- @file_put_contents($__lockFile, json_encode(['uid' => $__uid, 'job' => $job, 'time' => time()]));
+
}
set_time_limit(60);
$result = ['finished' => true, 'total' => 0, 'done' => 0, 'current' => ''];
+ try {
if ($job === 'rebuild') {
- $listFile = pp_data_file() . '/rebuild_list.json';
- $jobFile = pp_data_file() . '/job.json';
- $state = pp_read_json($jobFile);
- if (($state['job'] ?? '') !== 'rebuild' || !file_exists($listFile)) {
- $rows = $db->fetchAll($db->select()->from(ImageRepository::table()));
- $list = [];
- foreach ($rows as $r) {
- if (!empty($r['original'])) {
- $list[] = [$r['id'], $r['original'], $r['full'], $r['thumb'], (int)($r['cid'] ?? 0)];
- }
- }
- pp_write_json($listFile, $list);
- pp_write_json($jobFile, ['job' => 'rebuild', 'total' => count($list), 'done' => 0, 'current' => '', 'failed' => 0]);
- }
- $list = pp_read_json($listFile);
- $state = pp_read_json($jobFile);
+ [$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); }
+ pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]);
$idx = (int)($state['done'] ?? 0);
$failed = (int)($state['failed'] ?? 0);
$batch = 3;
@@ -348,8 +339,13 @@ function pp_exif_summary(array $exif): string
if ((microtime(true) - $started) > $budget) {
break;
}
- $item = $list[$idx];
- $src = ImageRepository::toAbs($item[1]);
+ $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;
@@ -378,27 +374,23 @@ function pp_exif_summary(array $exif): string
$idx++;
}
foreach (array_keys($syncedCids) as $__c) {
- try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) {}
+ try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) {
+ Plugin::log('rebuild sync: cid=' . $__c . ' ' . $e->getMessage());
+ $failed++;
+ $state['current'] = 'cid ' . $__c . '(字段同步失败,可重建字段重试)';
+ }
}
$state['done'] = $idx;
$state['failed'] = $failed;
$state['finished'] = $idx >= $total;
- if ($state['finished']) {
- $state = ['job' => 'rebuild', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true, 'failed' => $failed];
- }
- pp_write_json($jobFile, $state);
- $result = ['finished' => $idx >= $total, 'total' => $total, 'done' => $idx, 'current' => $state['current'], 'failed' => $failed];
+ MaintenanceState::save(pp_data_file(), $state);
+ $result = $state;
} elseif ($job === 'cleanup') {
- $listFile = pp_data_file() . '/cleanup_list.json';
- $jobFile = pp_data_file() . '/job.json';
- $state = pp_read_json($jobFile);
- if (($state['job'] ?? '') !== 'cleanup' || !file_exists($listFile)) {
- $list = ImageRepository::orphanCandidates();
- pp_write_json($listFile, $list);
- pp_write_json($jobFile, ['job' => 'cleanup', 'total' => count($list), 'done' => 0, 'current' => '']);
- }
- $list = pp_read_json($listFile);
- $state = pp_read_json($jobFile);
+ [$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); }
+ pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]);
$idx = (int)($state['done'] ?? 0);
$batch = 50;
ImageRepository::removeOrphanCandidates(array_slice($list, $idx, $batch));
@@ -408,33 +400,25 @@ function pp_exif_summary(array $exif): string
}
$state['done'] = $idx;
$state['finished'] = $idx >= count($list);
- if ($state['finished']) {
- @unlink($listFile);
- $state = ['job' => 'cleanup', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true];
- }
- pp_write_json($jobFile, $state);
- $result = ['finished' => $idx >= count($list), 'total' => count($list), 'done' => $idx, 'current' => $state['current']];
+ MaintenanceState::save(pp_data_file(), $state);
+ $result = $state;
} elseif ($job === 'resync') {
// 重算每篇图集的聚合字段(addresses/titles/descs/panos,并补上 dims 宽高数组)。
// 用于给已发布的历史文章补齐文章字段,使首页瀑布流能拿到图片比例做占位。
- $listFile = pp_data_file() . '/resync_list.json';
- $jobFile = pp_data_file() . '/job.json';
- $state = pp_read_json($jobFile);
- if (($state['job'] ?? '') !== 'resync' || !file_exists($listFile)) {
+ [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () use ($db, $prefix) {
$cids = [];
- foreach ($db->fetchAll($db->select('cid')->from(ImageRepository::table())) as $r) {
+ foreach (AdminRepository::readAll($db->select('cid')->from(ImageRepository::table()), true) as $r) {
$cids[(int)$r['cid']] = true;
}
- foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img')) as $r) {
+ foreach (AdminRepository::readAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img'), true) as $r) {
$cids[(int)$r['cid']] = true;
}
$list = array_values(array_filter(array_map('intval', array_keys($cids))));
sort($list);
- pp_write_json($listFile, $list);
- pp_write_json($jobFile, ['job' => 'resync', 'total' => count($list), 'done' => 0, 'current' => '']);
- }
- $list = pp_read_json($listFile);
- $state = pp_read_json($jobFile);
+ return $list;
+ });
+ if (!empty($state['finished'])) { pp_reply_json(true, '', $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 超时
@@ -461,12 +445,8 @@ function pp_exif_summary(array $exif): string
$state['done'] = $idx;
$state['failed'] = $failed;
$state['finished'] = $idx >= $total;
- if ($state['finished']) {
- $state = ['job' => 'resync', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true, 'failed' => $failed];
- @unlink($listFile);
- }
- pp_write_json($jobFile, $state);
- $result = ['finished' => $idx >= $total, 'total' => $total, 'done' => $idx, 'current' => $state['current'], 'failed' => $failed];
+ MaintenanceState::save(pp_data_file(), $state);
+ $result = $state;
} elseif ($job === 'albums_html') {
// 局部刷新图集列表:只返回卡片 HTML,避免为刷新列表重新渲染整个后台页面
header('Content-Type: text/html; charset=utf-8');
@@ -491,6 +471,11 @@ function pp_exif_summary(array $exif): string
exit;
}
+ } catch (\Throwable $e) {
+ Plugin::log('maintenance: ' . $e->getMessage());
+ pp_reply_json(false, $e->getMessage(), ['retryable' => true], 500);
+ }
+
if ($ppMaintenance && !empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); }
if ($ppMaintenance && empty($_POST['ajax'])) {
pp_reply('维护任务已处理 ' . (int)($result['done'] ?? 0) . ' / ' . (int)($result['total'] ?? 0) . (!empty($result['finished']) ? ',已完成' : ';再次提交可继续'));
@@ -568,7 +553,7 @@ function pp_exif_summary(array $exif): string
if ($cid > 0) {
$title = trim((string)($_POST['title'] ?? ''));
if ($title !== '') {
- $db->query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid));
+ Database::query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid));
}
pp_set_field($cid, 'device', trim((string)($_POST['device'] ?? '')));
pp_set_field($cid, 'tags', trim((string)($_POST['tags'] ?? '')));
@@ -614,7 +599,7 @@ function pp_exif_summary(array $exif): string
if ($rid <= 0) {
continue;
}
- $db->query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid));
+ Database::query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid));
$order++;
}
if ($cid > 0) {
@@ -631,7 +616,7 @@ function pp_exif_summary(array $exif): string
$row = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true);
if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限删除该图片'); }
ImageRepository::unlinkFiles($row['original'], $row['full'], $row['mid'] ?? null, $row['avif'] ?? null, $row['mid_avif'] ?? null, $row['thumb']);
- $db->query($db->delete(ImageRepository::table())->where('id = ?', $rowId));
+ Database::query($db->delete(ImageRepository::table())->where('id = ?', $rowId));
ImageRepository::syncPostFields((int)$row['cid'], true);
} else {
pp_deny('没有权限删除该图片');
@@ -644,18 +629,18 @@ function pp_exif_summary(array $exif): string
pp_require_admin(); // 一键清理全站非插件文章:仅管理员
// 汇总“本插件发布的图集”cid:有 img 自定义字段 或 在 infinitytime_images 表里
$pluginCids = [];
- foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name IN (?, ?)', 'img', AdminRepository::MARKER)) as $f) {
+ foreach (AdminRepository::readAll($db->select('cid')->from($prefix . 'fields')->where('name IN (?, ?)', 'img', AdminRepository::MARKER), true) as $f) {
$pluginCids[(int)$f['cid']] = true;
}
- foreach ($db->fetchAll($db->select('cid')->from(ImageRepository::table())) as $f) {
+ foreach (AdminRepository::readAll($db->select('cid')->from(ImageRepository::table()), true) as $f) {
$pluginCids[(int)$f['cid']] = true;
}
- $rows = $db->fetchAll($db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post'));
+ $rows = AdminRepository::readAll($db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post'), true);
$nonCount = 0;
$titles = [];
$isDelete = ($action === 'delete_non_plugin');
// 注意:SQLite 只支持 BEGIN,不支持 MySQL 的 START TRANSACTION(BEGIN 在两者都可用)
- if ($isDelete) { $db->query('BEGIN'); }
+ if ($isDelete) { Database::query('BEGIN'); }
try {
foreach ($rows as $r) {
$cid = (int)$r['cid'];
@@ -664,15 +649,15 @@ function pp_exif_summary(array $exif): string
}
$nonCount++;
if ($isDelete) {
- $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid));
- $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid));
+ Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid));
+ Database::query($db->delete($prefix . 'contents')->where('cid = ?', $cid));
} elseif (count($titles) < 20) {
$titles[] = (string)($r['title'] ?? '');
}
}
- if ($isDelete) { $db->query('COMMIT'); }
+ if ($isDelete) { Database::query('COMMIT'); }
} catch (\Throwable $e) {
- if ($isDelete) { $db->query('ROLLBACK'); }
+ if ($isDelete) { Database::query('ROLLBACK'); }
Plugin::log('delete_non_plugin failed: ' . $e->getMessage());
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'msg' => '清理失败:' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
@@ -706,8 +691,8 @@ function pp_exif_summary(array $exif): string
if (!pp_can_edit_cid($cid)) { pp_deny('没有权限删除该图集'); }
if ($cid > 0) {
ImageRepository::removeFor($cid);
- $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid));
- $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid));
+ Database::query($db->delete($prefix . 'contents')->where('cid = ?', $cid));
+ Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid));
}
if ($ajax) {
header('Content-Type: application/json; charset=utf-8');
@@ -963,7 +948,8 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string
}
}
$ppOperationKey = bin2hex(random_bytes(16));
-$ppJobState = pp_read_json(pp_data_file() . '/job.json');
+try { $ppJobState = pp_read_json(pp_data_file() . '/job.json'); }
+catch (\Throwable $e) { $ppJobState = []; $notice = $e->getMessage(); $noticeType = 'error'; }
/* ---------------------------------- 视图 ---------------------------------- */
$adminDir = dirname($_SERVER['SCRIPT_FILENAME']);
diff --git a/usr/themes/InfinityTime/index.php b/usr/themes/InfinityTime/index.php
index 2e2e32a..82554f0 100755
--- a/usr/themes/InfinityTime/index.php
+++ b/usr/themes/InfinityTime/index.php
@@ -3,7 +3,7 @@
* 一款简约的相册主题
* @package 无限时光
* @author InfinityTime
- * @version 1.14.0
+ * @version 1.14.1
* @link https://github.com/InfinityTime/InfinityTime
*/
?>
diff --git a/usr/themes/InfinityTime/releases.json b/usr/themes/InfinityTime/releases.json
index 64630e0..7875a65 100644
--- a/usr/themes/InfinityTime/releases.json
+++ b/usr/themes/InfinityTime/releases.json
@@ -1,4 +1,4 @@
{
- "tag_name": "1.14.0"
+ "tag_name": "1.14.1"
}
diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md
index 0362677..e2a5b82 100644
--- a/usr/themes/InfinityTime/update.md
+++ b/usr/themes/InfinityTime/update.md
@@ -1,5 +1,13 @@
# 更新日志
+## 1.14.1
+
+- 修复连续重建复用过期清单:新任务重新扫描,未完成任务保留断点,任务标识拒绝迟到轮询;状态落盘失败明确提示。
+- 图集聚合字段通过主库保存点原子同步,故障保留旧字段并保持外层事务;重建时同步错误计入失败。MySQL 非 InnoDB 字段表安全拒绝。
+- 统一插件主库查询入口,修复 Typecho Mysqli 读写连接与插入标识边界;非插件文章清理从主库确认身份并正确路由事务。
+- 孤儿引用扫描只读取六个路径列,显著降低含大 EXIF 图库的读取数据量,同时保留每批引用与文件身份复核。
+- 增加 PHP 7.4 / 8.2 / 8.4、Typecho 1.2.1 / 1.3.0、SQLite / MySQL / PostgreSQL 的真实数据库集成矩阵和维护故障注入回归。
+
## 1.14.0
- 后台草稿优先、逐图串行上传及持久化重试标识,支持成功项恢复和仓库相册追加。
From 8dc3cf9fa82e137ecb6bddc484cf8ebf78af812f Mon Sep 17 00:00:00 2001
From: Thregren <37279823+Thregren@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:09:56 +0800
Subject: [PATCH 2/4] =?UTF-8?q?=E5=85=BC=E5=AE=B9PHP7.4=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=E8=AF=AD=E6=B3=95=EF=BC=8C=E5=A2=9E=E5=8A=A0=E9=9D=9E=E4=BA=8B?=
=?UTF-8?q?=E5=8A=A1=E8=A1=A8=E4=B8=8E=E4=B8=BB=E4=BB=8E=E9=9A=94=E7=A6=BB?=
=?UTF-8?q?=E6=95=85=E9=9A=9C=E9=AA=8C=E8=AF=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/gallery-discovery.php | 54 ++++----
tests/repository.php | 3 +-
tests/typecho-integration.php | 235 ++++++++++++++++++++--------------
3 files changed, 170 insertions(+), 122 deletions(-)
diff --git a/tests/gallery-discovery.php b/tests/gallery-discovery.php
index ee991e4..5bbc2cb 100644
--- a/tests/gallery-discovery.php
+++ b/tests/gallery-discovery.php
@@ -1,12 +1,13 @@
columns = $columns; }
public function from(string $table): self { $this->table = $table; return $this; }
public function where(string $sql, ...$args): self { $this->conditions[$sql] = $args; return $this; }
public function order(...$args): self { return $this; }
@@ -23,24 +24,23 @@ public function getPrefix(): string { return 'test_'; }
public function select(...$columns): Query { return new Query($columns); }
public function fetchAll(Query $query): array {
$this->queries[] = $query;
- $rows = match ($query->table) {
- 'test_contents' => $this->albums, 'test_infinitytime_images' => $this->images,
- 'test_fields' => $this->fields, 'test_relationships' => $this->metas,
- default => throw new \RuntimeException('未预期的数据表:' . $query->table),
- };
+ $tables = ['test_contents' => $this->albums, 'test_infinitytime_images' => $this->images,
+ 'test_fields' => $this->fields, 'test_relationships' => $this->metas];
+ if (!isset($tables[$query->table])) { throw new \RuntimeException('未预期的数据表:' . $query->table); }
+ $rows = $tables[$query->table];
foreach ($query->conditions as $sql => $values) {
$rows = array_values(array_filter($rows, static function ($r) use ($sql, $values): bool {
- return match ($sql) {
- 'type = ?' => $r['type'] === $values[0],
- 'status = ?' => $r['status'] === $values[0],
- '(password IS NULL OR password = ?)' => ($r['password'] ?? '') === '',
- 'created <= ?' => $r['created'] <= $values[0],
- 'cid <= ?' => $r['cid'] <= $values[0],
- 'cid = ?' => $r['cid'] === $values[0],
- 'id = ?' => $r['id'] === $values[0],
- default => str_contains($sql, 'cid IN') ? in_array($r['cid'], $values, true)
- : (str_contains($sql, 'name IN') ? in_array($r['name'], $values, true) : true),
- };
+ switch ($sql) {
+ case 'type = ?': return $r['type'] === $values[0];
+ case 'status = ?': return $r['status'] === $values[0];
+ case '(password IS NULL OR password = ?)': return ($r['password'] ?? '') === '';
+ case 'created <= ?': return $r['created'] <= $values[0];
+ case 'cid <= ?': return $r['cid'] <= $values[0];
+ case 'cid = ?': return $r['cid'] === $values[0];
+ case 'id = ?': return $r['id'] === $values[0];
+ default: return strpos($sql, 'cid IN') !== false ? in_array($r['cid'], $values, true)
+ : (strpos($sql, 'name IN') !== false ? in_array($r['name'], $values, true) : true);
+ }
}));
}
if ($query->table === 'test_contents') { usort($rows, static fn($a, $b) => $b['cid'] <=> $a['cid']); }
@@ -72,7 +72,11 @@ public function __call(string $name, array $args): void {
}
class GalleryFixture {
public GalleryFixtureOptions $options;
- public function __construct(public string $archiveType = '', public string $archiveTitle = '', public string $archiveSlug = '') { $this->options = new GalleryFixtureOptions(); }
+ public string $archiveType, $archiveTitle, $archiveSlug;
+ public function __construct(string $archiveType = '', string $archiveTitle = '', string $archiveSlug = '') {
+ $this->archiveType = $archiveType; $this->archiveTitle = $archiveTitle; $this->archiveSlug = $archiveSlug;
+ $this->options = new GalleryFixtureOptions();
+ }
public function is(string $type): bool { return $type === $this->archiveType; }
public function getArchiveTitle(): string { return $this->archiveTitle; }
public function getArchiveSlug(): string { return $this->archiveSlug; }
@@ -178,8 +182,8 @@ public function render(): void { include __DIR__ . '/../usr/themes/InfinityTime/
$album['photos'][0]['title'] = "'\">";
$__galleryBase = 'https://example.test/'; $__galleryRequest = pp_gallery_request([]); $month = '2026-04';
ob_start(); include __DIR__ . '/../usr/themes/InfinityTime/lib/gallery-card.php'; $html = ob_get_clean();
- check(!str_contains($html, '
+
${['jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(name => ``).join('')}