Files
laralog/app/Console/Commands/SablogImport.php
T
gouki 86effba075
Build and Deploy / verify (push) Failing after 30m11s
Build and Deploy / deploy (push) Skipped
fix(import): dedupe tags via Tag::findFromString (name is JSON translatable)
2026-10-12 12:44:39 +00:00

454 lines
16 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\Blog\Services\PostContentRenderer;
use App\Blog\Support\SablogHtmlNormalizer;
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 League\HTMLToMarkdown\HtmlConverter;
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->importStylevars();
$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 —— 导入为当前主题的 theme_vars(广告位)
* 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 importStylevars(): void
{
if (! $this->hasTable('stylevars')) {
return;
}
$vars = [];
foreach ($this->rows('stylevars') as $row) {
$name = (string) ($row['title'] ?? $row['variable'] ?? $row['name'] ?? '');
$value = (string) ($row['value'] ?? $row['content'] ?? '');
if ($name === '' || $value === '') {
continue;
}
$vars[$name] = $value;
}
if ($vars === []) {
return;
}
$theme = (string) Setting::get('active_theme', 'sablog');
$existing = Setting::get('theme_vars_'.$theme, []);
$existing = is_array($existing) ? $existing : (json_decode((string) $existing, true) ?: []);
Setting::set('theme_vars_'.$theme, array_merge($existing, $vars));
$this->report['stylevars'] = count($vars);
}
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'],
'sticky_level' => ((bool) $row['stick']) ? 1 : 0,
'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;
// 正确的标签来源:sablog_articles.keywords(每篇文章的关键词字段)
// 而不是 sablog_tags.aids(那个映射是错的/过时的)
foreach ($this->rows('articles') as $row) {
$keywords = trim((string) ($row['keywords'] ?? ''));
if ($keywords === '') {
continue;
}
$postId = (int) $row['articleid'];
$post = Post::find($postId);
if (! $post) {
continue;
}
// keywords 是逗号分隔的标签名
$tagNames = array_filter(array_map('trim', explode(',', $keywords)));
foreach ($tagNames as $tagName) {
if ($tagName === '') {
continue;
}
// spatie/tags 的 name 是 JSON 翻译字段,直接 where('name', ...) 匹配不到,
// 必须用 findFromString(内部按 locale 查询 JSON 键),否则每次导入都重复建标签
$tag = Tag::findFromString($tagName, 'post');
if (! $tag) {
$tag = new Tag(['name' => $tagName, 'type' => 'post']);
$tag->slug = SlugGenerator::make($tagName, 'tags', 'slug', fallback: 'tag-'.crc32($tagName));
$tag->save();
}
$legacyMap[$tagName] = $tag->id;
if (! $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 HtmlConverter([
'strip_tags' => false,
'hard_break' => true,
]);
$renderer = app(PostContentRenderer::class);
$normalizer = app(SablogHtmlNormalizer::class);
$count = 0;
foreach (Post::query()->where('content_format', 'html')->cursor() as $post) {
$post->content = $normalizer->toMarkdown((string) $post->content, $converter, $renderer);
$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")')]);
}
}