Files
laralog/app/Console/Commands/SablogImport.php
T
gouki e0c709ad5c
Build and Deploy / verify (push) Successful in 1m0s
Build and Deploy / deploy (push) Successful in 33s
fix(import): null out orphan category references (FK safe)
2026-09-07 19:55:19 +00:00

408 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Services\AttachmentImporter;
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 App\Support\SlugGenerator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PDO;
use Spatie\Tags\Tag;
class SablogImport extends Command
{
protected $signature = 'sablog:import
{--driver=mysql : 老库驱动(mysql / sqlite,测试可用 sqlite}
{--host=127.0.0.1 : 老库主机}
{--port=3306 : 老库端口}
{--database= : 老库名(必填)}
{--username=root : 老库用户名}
{--password= : 老库密码}
{--prefix=sablog_ : 老表前缀}
{--attachments-dir= : 老 attachments 目录(用于同步文件,可选)}
{--sync-attachments : 同步附件文件到新存储}
{--convert-markdown : 导入后把 HTML 内容转为 Markdown[attach=xx] 转成 {{attach:xx}} 令牌,渲染时解析)}
{--fresh : 清空目标表后重新导入}';
protected $description = '从 SaBlog-X 老库脚本迁移数据到 laralog';
private PDO $db;
private string $prefix;
private array $report = [];
public function handle(): int
{
$database = $this->option('database');
if (! $database) {
$this->error('必须指定 --database 老库名');
return self::FAILURE;
}
$this->prefix = rtrim($this->option('prefix'), '_').'_';
$this->info("连接老库 {$database} ...");
$this->connect($database);
if ($this->option('fresh')) {
$this->freshTarget();
}
$this->importSettings();
$this->importCategories();
$this->importUsers();
$this->importPosts();
$this->importTags();
$this->importComments();
$this->importLinks();
$this->importAttachments();
if ($this->option('convert-markdown')) {
$this->convertToMarkdown();
}
$this->syncCounters();
$this->newLine();
$this->info('迁移完成,报告:');
foreach ($this->report as $table => $count) {
$this->line(sprintf(' %-16s %d 条', $table, $count));
}
return self::SUCCESS;
}
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(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$this->option('host'),
$this->option('port'),
$database
);
$this->db = new PDO($dsn, $this->option('username'), $this->option('password'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
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
{
return $this->db->query('SELECT * FROM `'.$this->prefix.$table.'`')->fetchAll();
}
private function freshTarget(): void
{
$this->warn('--fresh:清空目标表...');
DB::table('media')->delete();
DB::table('taggables')->delete();
Tag::where('type', 'post')->delete();
DB::table('posts')->delete();
DB::table('categories')->delete();
DB::table('comments')->delete();
DB::table('links')->delete();
Setting::where('key', 'like', 'sablog.%')->delete();
}
/**
* 不迁移的 sablog 表(过时/垃圾化设计,由现代方案取代):
* trackbacks/trackbacklog —— 引用通告,垃圾来源
* searchindex —— 搜索缓存,现代 SQL 全文检索足够
* sessions —— Laravel 原生会话
* statistics —— 实时统计
* stylevars —— 老式广告位变量
* seccode / 验证码 —— 由 AI 审核 + 蜜罐 + 频控取代
*/
private function importSettings(): void
{
$map = [
'name' => 'site_name',
'description' => 'site_description',
'icp' => 'site_icp',
'templatename' => 'active_theme',
'audit_comment' => 'comment_audit',
'comment_min_len' => 'comment_min_len',
'comment_max_len' => 'comment_max_len',
'comment_post_space' => 'comment_post_space',
'comment_order' => 'comment_order',
'rss_num' => 'rss_num',
'title_keywords' => 'seo_default_keywords',
'meta_keywords' => 'seo_default_keywords',
'meta_description' => 'seo_default_description',
'close' => 'site_closed',
'close_note' => 'site_closed_note',
'banip_enable' => 'comment_ban_ip_enabled',
'ban_ip' => 'comment_ban_ip',
];
$count = 0;
foreach ($this->rows('settings') as $row) {
$key = $map[$row['title']] ?? 'sablog.'.$row['title'];
if ($key === 'active_theme' && $row['value'] === 'default') {
$row['value'] = 'sablog';
}
Setting::updateOrCreate(['key' => $key], ['value' => $row['value']]);
$count++;
}
$this->report['settings'] = $count;
}
private function importCategories(): void
{
$count = 0;
foreach ($this->rows('categories') as $row) {
$slug = SlugGenerator::make($row['name'], 'categories', 'slug', ignoreId: (int) $row['cid'], fallback: 'category-'.$row['cid']);
DB::table('categories')->updateOrInsert(['id' => (int) $row['cid']], [
'name' => $row['name'],
'slug' => $slug,
'display_order' => (int) $row['displayorder'],
'post_count' => (int) $row['articles'],
'created_at' => now(),
'updated_at' => now(),
]);
$count++;
}
$this->report['categories'] = $count;
}
private function importUsers(): void
{
$groupMap = [
1 => 'admin',
2 => 'editor',
3 => 'member',
];
$count = 0;
foreach ($this->rows('users') as $row) {
$email = $this->legacyEmail($row['username'], $count);
DB::table('users')->updateOrInsert(['id' => (int) $row['userid']], [
'name' => $row['username'],
'email' => $email,
'password' => null,
'legacy_md5' => $row['password'],
'url' => $row['url'] ?: null,
'logincount' => (int) $row['logincount'],
'loginip' => $row['loginip'] ?: null,
'regip' => $row['regip'] ?: null,
'logintime' => $row['logintime'] ? date('Y-m-d H:i:s', (int) $row['logintime']) : null,
'lastpost_at' => $row['lastpost'] ? date('Y-m-d H:i:s', (int) $row['lastpost']) : null,
'created_at' => ($ts = (int) $row['regdateline']) > 0 ? date('Y-m-d H:i:s', $ts) : now(),
'updated_at' => now(),
]);
$user = User::find((int) $row['userid']);
$role = $groupMap[(int) $row['groupid']] ?? 'member';
if ($user && ! $user->hasRole($role)) {
$user->assignRole($role);
}
$count++;
}
$this->report['users'] = $count;
}
private function legacyEmail(string $username, int $index): string
{
$base = strtolower(preg_replace('/[^a-z0-9._-]/i', '', $username)) ?: 'user';
return $index === 0 ? $base.'@legacy.local' : $base.'-'.$index.'@legacy.local';
}
private function importPosts(): void
{
$count = 0;
foreach ($this->rows('articles') as $row) {
$slug = SlugGenerator::make($row['title'], 'posts', 'slug', ignoreId: (int) $row['articleid'], fallback: 'post-'.$row['articleid']);
$status = (int) $row['visible'] === 1 ? 'published' : 'draft';
$dateline = ($ts = (int) $row['dateline']) > 0 ? date('Y-m-d H:i:s', $ts) : now()->toDateTimeString();
// 老库存在已删除分类的孤儿文章;目标列可空,置 null
$categoryId = (int) $row['cid'] ?: null;
if ($categoryId !== null && ! DB::table('categories')->where('id', $categoryId)->exists()) {
$categoryId = null;
}
DB::table('posts')->updateOrInsert(['id' => (int) $row['articleid']], [
'category_id' => $categoryId,
'user_id' => (int) $row['uid'] ?: null,
'title' => $row['title'],
'slug' => $slug,
'excerpt' => $row['description'] ?: null,
'content' => $row['content'],
'content_format' => 'html',
'keywords' => $row['keywords'] ?: null,
'status' => $status,
'is_sticky' => (bool) $row['stick'],
'close_comment' => (bool) $row['closecomment'],
'read_password' => $row['readpassword'] ?: null,
'views' => (int) $row['views'],
'comment_count' => (int) $row['comments'],
'published_at' => $status === 'published' ? $dateline : null,
'created_at' => $dateline,
'updated_at' => $dateline,
]);
$count++;
}
$this->report['posts'] = $count;
}
private function importTags(): void
{
$legacyMap = [];
$count = 0;
foreach ($this->rows('tags') as $row) {
$tagName = trim($row['tag']);
if (! $tagName) {
continue;
}
$tag = Tag::query()->where('name', $tagName)->where('type', 'post')->first();
if (! $tag) {
$tag = new Tag(['name' => $tagName, 'type' => 'post']);
$tag->slug = SlugGenerator::make($tagName, 'tags', 'slug', fallback: 'tag-'.crc32($tagName));
$tag->save();
}
$legacyMap[(int) $row['tagid']] = $tag->id;
foreach (explode(',', (string) $row['aids']) as $aid) {
$post = Post::find((int) trim($aid));
if ($post && ! $post->tags()->where('tags.id', $tag->id)->exists()) {
$post->tags()->attach($tag->id);
}
}
$count++;
}
if ($legacyMap) {
Setting::set('legacy_tags', $legacyMap);
}
$this->report['tags'] = $count;
}
private function importComments(): void
{
$count = 0;
foreach ($this->rows('comments') as $row) {
DB::table('comments')->updateOrInsert(['id' => (int) $row['commentid']], [
'post_id' => (int) $row['articleid'],
'author_name' => $row['author'] ?: '匿名',
'author_url' => $row['url'] ?: null,
'content' => $row['content'],
'ip' => $row['ipaddress'] ?: null,
'status' => (int) $row['visible'] === 1 ? 'published' : 'pending',
'created_at' => ($ts = (int) $row['dateline']) > 0 ? date('Y-m-d H:i:s', $ts) : null,
]);
$count++;
}
$this->report['comments'] = $count;
}
private function importLinks(): void
{
$count = 0;
foreach ($this->rows('links') as $row) {
DB::table('links')->updateOrInsert(['id' => (int) $row['linkid']], [
'name' => $row['name'],
'url' => $row['url'],
'note' => $row['note'] ?: null,
'display_order' => (int) $row['displayorder'],
'visible' => (bool) $row['visible'],
'created_at' => now(),
'updated_at' => now(),
]);
$count++;
}
$this->report['links'] = $count;
}
private function convertToMarkdown(): void
{
$this->info('转换文章内容为 Markdown ...');
$converter = new \League\HTMLToMarkdown\HtmlConverter([
'strip_tags' => false,
'hard_break' => true,
]);
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
$count = 0;
foreach (Post::query()->where('content_format', 'html')->cursor() as $post) {
$post->content = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
$post->content_format = 'markdown';
$post->save();
$count++;
}
$this->report['markdown_converted'] = $count;
}
private function importAttachments(): void
{
if (! $this->hasTable('attachments')) {
return;
}
$importer = app(AttachmentImporter::class);
$dir = $this->option('attachments-dir');
$sync = $this->option('sync-attachments');
$count = 0;
foreach ($this->rows('attachments') as $row) {
$importer->create((int) $row['articleid'], $row, $dir ?: null, $sync);
$count++;
}
$this->report['attachments'] = $count;
}
private function syncCounters(): void
{
// 以导入的真实数据为准重算统计计数
DB::table('posts')->update(['comment_count' => DB::raw('(SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id AND comments.status = "published")')]);
DB::table('categories')->update(['post_count' => DB::raw('(SELECT COUNT(*) FROM posts WHERE posts.category_id = categories.id AND posts.status = "published")')]);
}
}