fix: [attach] 短代码跨文章全局解析 + 缺失残留清理;损坏附件导入降级不中断;media 导入幂等;sablog:import 支持 sqlite 老库 + 5 个导入自动化测试

This commit is contained in:
ak
2026-08-11 19:16:16 +08:00
parent e735967218
commit 34ea046906
92 changed files with 340 additions and 12 deletions
+44 -8
View File
@@ -16,6 +16,19 @@ class AttachmentImporter
*/ */
public function create(int $postId, array $row, ?string $attachmentsDir, bool $syncNow): Media public function create(int $postId, array $row, ?string $attachmentsDir, bool $syncNow): Media
{ {
$legacyId = (int) ($row['attachmentid'] ?? 0);
// 幂等:同一老附件已成功导入过则直接复用
$existing = Media::query()
->where('collection_name', 'attachments')
->where('custom_properties->legacy_attachmentid', $legacyId)
->latest('id')
->first();
if ($existing && ! $existing->getCustomProperty('pending_sync')) {
return $existing;
}
$post = Post::find($postId); $post = Post::find($postId);
// 游离附件(articleid=0 或对应文章不存在)统一挂到 id=0 占位文章上 // 游离附件(articleid=0 或对应文章不存在)统一挂到 id=0 占位文章上
@@ -36,16 +49,39 @@ class AttachmentImporter
$source = $this->locateSource($row['filepath'] ?? null, $attachmentsDir); $source = $this->locateSource($row['filepath'] ?? null, $attachmentsDir);
if ($source && $syncNow) { if ($source && $syncNow) {
$media = $post->addMedia($source) try {
->preservingOriginal() $media = $post->addMedia($source)
->withCustomProperties($properties) ->preservingOriginal()
->usingFileName(basename($row['filename']) ?: basename($source)) ->withCustomProperties($properties)
->toMediaCollection('attachments', MediaDisk::name()); ->usingFileName(basename($row['filename']) ?: basename($source))
->toMediaCollection('attachments', MediaDisk::name());
$media->setCustomProperty('pending_sync', false); $media->setCustomProperty('pending_sync', false);
$media->save(); $media->save();
return $media; return $media;
} catch (\Throwable $e) {
// addMedia 可能已插入记录并保存文件,仅缩略图生成失败:找回该记录标记完成
$existing = Media::query()
->where('collection_name', 'attachments')
->where('custom_properties->legacy_attachmentid', (int) ($row['attachmentid'] ?? 0))
->latest('id')
->first();
if ($existing) {
$existing->setCustomProperty('pending_sync', false);
$existing->save();
return $existing;
}
// 文件损坏无法入库时降级为 pending,不中断整批导入
\Illuminate\Support\Facades\Log::warning('附件导入失败,已降级为待同步', [
'attachment' => $row['attachmentid'] ?? null,
'file' => $source,
'error' => $e->getMessage(),
]);
}
} }
return $this->createPendingRecord($post->id, $row, $properties); return $this->createPendingRecord($post->id, $row, $properties);
+14 -3
View File
@@ -3,10 +3,12 @@
namespace App\Blog\Services; namespace App\Blog\Services;
use App\Models\Post; use App\Models\Post;
use Illuminate\Support\Facades\Cache;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\MarkdownConverter; use League\CommonMark\MarkdownConverter;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class PostContentRenderer class PostContentRenderer
{ {
@@ -63,9 +65,16 @@ class PostContentRenderer
return $html; return $html;
} }
$mediaItems = $post->getMedia('attachments'); // 短代码引用的是 sablog 全局附件 id,可能属于其他文章,需要全局查
$byLegacyId = $mediaItems->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid')); $byLegacyId = Cache::remember('attach.legacy_ids', now()->addHour(), function () {
$ordered = $mediaItems->values(); return Media::query()
->where('collection_name', 'attachments')
->get()
->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid'));
});
// 当前文章媒体用于索引回退
$ordered = $post->getMedia('attachments')->values();
foreach ($matches as $match) { foreach ($matches as $match) {
$full = $match[0]; $full = $match[0];
@@ -80,6 +89,8 @@ class PostContentRenderer
} }
if (! $media) { if (! $media) {
// 找不到媒体时移除残留短代码,避免把 [attach=999] 当正文显示
$html = str_replace($full, '', $html);
continue; continue;
} }
+24 -1
View File
@@ -18,6 +18,7 @@ use Spatie\Tags\Tag;
class SablogImport extends Command class SablogImport extends Command
{ {
protected $signature = 'sablog:import protected $signature = 'sablog:import
{--driver=mysql : 老库驱动(mysql / sqlite,测试可用 sqlite}
{--host=127.0.0.1 : 老库主机} {--host=127.0.0.1 : 老库主机}
{--port=3306 : 老库端口} {--port=3306 : 老库端口}
{--database= : 老库名(必填)} {--database= : 老库名(必填)}
@@ -81,6 +82,17 @@ class SablogImport extends Command
private function connect(string $database): void private function connect(string $database): void
{ {
$driver = $this->option('driver');
if ($driver === 'sqlite') {
$this->db = new PDO('sqlite:'.$database, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return;
}
$dsn = sprintf( $dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$this->option('host'), $this->option('host'),
@@ -94,6 +106,17 @@ class SablogImport extends Command
]); ]);
} }
private function hasTable(string $table): bool
{
try {
$this->db->query("SELECT COUNT(*) FROM `{$this->prefix}{$table}` LIMIT 1");
return true;
} catch (\Throwable) {
return false;
}
}
private function rows(string $table): array private function rows(string $table): array
{ {
return $this->db->query('SELECT * FROM `'.$this->prefix.$table.'`')->fetchAll(); return $this->db->query('SELECT * FROM `'.$this->prefix.$table.'`')->fetchAll();
@@ -349,7 +372,7 @@ class SablogImport extends Command
private function importAttachments(): void private function importAttachments(): void
{ {
if (! $this->db->query('SHOW TABLES LIKE "'.$this->prefix.'attachments"')->fetchColumn()) { if (! $this->hasTable('attachments')) {
return; return;
} }
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Link;
use App\Models\Post;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use PDO;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Tags\Tag;
use Tests\TestCase;
class SablogImportTest extends TestCase
{
use RefreshDatabase;
private string $legacyDb;
private string $attachmentsDir;
protected function setUp(): void
{
parent::setUp();
$this->seed();
$this->legacyDb = storage_path('app/tmp/sablog-fixture-'.uniqid().'.sqlite');
$this->attachmentsDir = storage_path('app/tmp/sablog-att-'.uniqid());
File::ensureDirectoryExists($this->attachmentsDir.'/2020/09');
File::put($this->attachmentsDir.'/2020/09/photo.jpg', 'fake-jpg');
File::put($this->attachmentsDir.'/2020/09/archive.zip', 'fake-zip');
$this->buildLegacyDb();
}
protected function tearDown(): void
{
File::delete($this->legacyDb);
File::deleteDirectory($this->attachmentsDir);
parent::tearDown();
}
private function buildLegacyDb(): void
{
$pdo = new PDO('sqlite:'.$this->legacyDb);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE sablog_articles (articleid INTEGER PRIMARY KEY, cid INTEGER, uid INTEGER, title TEXT, description TEXT, content TEXT, keywords TEXT, dateline INTEGER, views INTEGER, comments INTEGER, attachments TEXT, trackbacks INTEGER, closecomment INTEGER, closetrackback INTEGER, visible INTEGER, stick INTEGER, readpassword TEXT)');
$pdo->exec('CREATE TABLE sablog_categories (cid INTEGER PRIMARY KEY, name TEXT, displayorder INTEGER, articles INTEGER)');
$pdo->exec('CREATE TABLE sablog_comments (commentid INTEGER PRIMARY KEY, articleid INTEGER, author TEXT, url TEXT, dateline INTEGER, content TEXT, ipaddress TEXT, visible INTEGER)');
$pdo->exec('CREATE TABLE sablog_links (linkid INTEGER PRIMARY KEY, displayorder INTEGER, name TEXT, url TEXT, note TEXT, visible INTEGER)');
$pdo->exec('CREATE TABLE sablog_tags (tagid INTEGER PRIMARY KEY, tag TEXT, usenum INTEGER, aids TEXT)');
$pdo->exec('CREATE TABLE sablog_settings (title TEXT PRIMARY KEY, value TEXT)');
$pdo->exec('CREATE TABLE sablog_users (userid INTEGER PRIMARY KEY, username TEXT, password TEXT, logincount INTEGER, loginip TEXT, logintime INTEGER, url TEXT, articles INTEGER, regdateline INTEGER, regip TEXT, groupid INTEGER, lastpost INTEGER)');
$pdo->exec('CREATE TABLE sablog_attachments (attachmentid INTEGER PRIMARY KEY, articleid INTEGER, dateline INTEGER, filename TEXT, filetype TEXT, filesize INTEGER, downloads INTEGER, filepath TEXT, thumb_filepath TEXT, thumb_width INTEGER, thumb_height INTEGER, isimage INTEGER)');
$now = time();
$pdo->exec("INSERT INTO sablog_categories VALUES (1, '生活随笔', 0, 1), (2, '技术分享', 1, 1)");
$pdo->exec("INSERT INTO sablog_users VALUES (1, '老管理员', '5f4dcc3b5aa765d61d8327deb882cf99', 5, '127.0.0.1', $now, 'https://example.com', 1, $now, '127.0.0.1', 1, $now)");
$pdo->exec("INSERT INTO sablog_articles VALUES (10, 1, 1, '第一篇老文章', '描述', '<p>正文 HTML</p><p>[attach=1]</p><p>[img=2]</p>', 'php,laravel', $now, 88, 1, 'a:1:{i:0;i:1;}', 0, 0, 0, 1, 0, '')");
$pdo->exec("INSERT INTO sablog_articles VALUES (11, 2, 1, '草稿文章', '', '<p>没写完</p>', '', $now, 0, 0, '', 0, 0, 0, 0, 0, '')");
$pdo->exec("INSERT INTO sablog_comments VALUES (5, 10, '访客', '', $now, '不错', '1.2.3.4', 1), (6, 10, '待审', '', $now, '请审核', '1.2.3.5', 0)");
$pdo->exec("INSERT INTO sablog_links VALUES (1, 0, '友链A', 'https://a.example.com', '', 1)");
$pdo->exec("INSERT INTO sablog_tags VALUES (1, 'laravel', 1, '10'), (2, '随笔', 1, '10')");
$pdo->exec("INSERT INTO sablog_settings VALUES ('name', '老站名'), ('description', '老描述'), ('templatename', 'default')");
$pdo->exec("INSERT INTO sablog_attachments VALUES (1, 10, $now, 'photo.jpg', 'image/jpeg', 100, 3, '2020/09/photo.jpg', '', 0, 0, 1), (2, 10, $now, 'archive.zip', 'application/zip', 200, 1, '2020/09/archive.zip', '', 0, 0, 0)");
}
private function import(array $extra = []): void
{
$params = [
'--driver' => 'sqlite',
'--database' => $this->legacyDb,
'--attachments-dir' => $this->attachmentsDir,
...$extra,
];
Artisan::call('sablog:import', $params);
}
public function test_imports_all_tables_preserving_ids(): void
{
$this->import(['--sync-attachments' => true]);
$this->assertSame(2, Post::count());
$this->assertSame(2, Category::count());
$this->assertSame(2, Comment::count());
$this->assertSame(1, Link::count());
$this->assertSame(2, Tag::count());
// 原始 ID 保留
$this->assertNotNull(Post::find(10));
$this->assertNotNull(Category::find(1));
// 老用户 id=1 覆盖 seed 的 admin(保 ID 语义)
$legacyAdmin = User::find(1);
$this->assertSame('老管理员', $legacyAdmin->name);
$this->assertTrue($legacyAdmin->hasRole('admin'));
$this->assertSame('5f4dcc3b5aa765d61d8327deb882cf99', $legacyAdmin->legacy_md5);
// 状态映射:visible=0 -> draft
$this->assertSame('draft', Post::find(11)->status);
// 评论状态映射:visible=0 -> pending
$this->assertSame('pending', Comment::find(6)->status);
// 角色映射:groupid=1 -> admin
$this->assertTrue(User::find(1)->hasRole('admin'));
// 设置映射
$this->assertSame('老站名', Setting::get('site_name'));
$this->assertSame('sablog', Setting::get('active_theme'));
}
public function test_import_converts_content_to_markdown(): void
{
$this->import(['--sync-attachments' => true, '--convert-markdown' => true]);
$post = Post::find(10);
$this->assertSame('markdown', $post->content_format);
$this->assertStringContainsString('{{attach:1}}', $post->content);
$this->assertStringNotContainsString('[attach=', $post->content);
// 渲染后 [attach] 解析为链接
$html = app(\App\Blog\Services\PostContentRenderer::class)->render($post);
$this->assertStringContainsString('photo.jpg', $html);
$this->assertStringNotContainsString('{{attach', $html);
}
public function test_import_keeps_html_format_without_conversion(): void
{
$this->import(['--sync-attachments' => true]);
$post = Post::find(10);
$this->assertSame('html', $post->content_format);
$this->assertStringContainsString('[attach=1]', $post->content);
// 渲染时解析短代码
$html = app(\App\Blog\Services\PostContentRenderer::class)->render($post);
$this->assertStringContainsString('photo.jpg', $html);
}
public function test_import_attachments_to_media_library(): void
{
$this->import(['--sync-attachments' => true]);
$this->assertSame(2, Media::query()->where('collection_name', 'attachments')->count());
$media = Media::query()->where('custom_properties->legacy_attachmentid', 1)->first();
$this->assertNotNull($media);
$this->assertFalse((bool) $media->getCustomProperty('pending_sync'));
$this->assertSame('photo.jpg', $media->file_name);
}
public function test_import_is_idempotent(): void
{
$this->import(['--sync-attachments' => true]);
$this->import(['--sync-attachments' => true]);
$this->assertSame(2, Post::count());
$this->assertSame(2, Comment::count());
$this->assertSame(2, Media::query()->where('collection_name', 'attachments')->count());
}
}