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")')]); } }