Files
laralog/app/Console/Commands/SablogImport.php
T
goukiandCursor 36c4ffc5b4
Build and Deploy / verify (push) Successful in 37s
Build and Deploy / deploy (push) Successful in 38s
fix: restyle pager chips and restore theme ad slots
Replace the loose page-count label with a SaBlog-style 6 / 316 chip, wire inject/stylevar HTML into list and post views, and flush page cache when ads are saved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 21:48:24 +00:00

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