M1: 数据模型 + sablog:import 脚本(MySQL 8 验证通过,不迁移 trackback 等过时功能)
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Support\MediaDisk;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class AttachmentImporter
|
||||
{
|
||||
/**
|
||||
* 将一条 sablog 附件记录写入媒体库。
|
||||
*
|
||||
* @param array{attachmentid:int,articleid:int,dateline:int,filename:string,filetype:string,filesize:int,downloads:int,filepath:string,thumb_filepath:string,thumb_width:int,thumb_height:int,isimage:int} $row
|
||||
*/
|
||||
public function create(int $postId, array $row, ?string $attachmentsDir, bool $syncNow): Media
|
||||
{
|
||||
$post = Post::find($postId);
|
||||
|
||||
// 游离附件(articleid=0 或对应文章不存在)统一挂到 id=0 占位文章上
|
||||
if (! $post) {
|
||||
$post = new Post(['id' => 0]);
|
||||
}
|
||||
|
||||
$properties = [
|
||||
'downloads' => (int) ($row['downloads'] ?? 0),
|
||||
'isimage' => (bool) ($row['isimage'] ?? false),
|
||||
'legacy_filepath' => $row['filepath'] ?? null,
|
||||
'legacy_thumb' => $row['thumb_filepath'] ?? null,
|
||||
'legacy_articleid' => (int) ($row['articleid'] ?? 0),
|
||||
'pending_sync' => true,
|
||||
];
|
||||
|
||||
$source = $this->locateSource($row['filepath'] ?? null, $attachmentsDir);
|
||||
|
||||
if ($source && $syncNow) {
|
||||
$media = $post->addMedia($source)
|
||||
->preservingOriginal()
|
||||
->withCustomProperties($properties)
|
||||
->usingFileName(basename($row['filename']) ?: basename($source))
|
||||
->toMediaCollection('attachments', MediaDisk::name());
|
||||
|
||||
$media->setCustomProperty('pending_sync', false);
|
||||
$media->save();
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
return $this->createPendingRecord($post->id, $row, $properties);
|
||||
}
|
||||
|
||||
private function locateSource(?string $filepath, ?string $attachmentsDir): ?string
|
||||
{
|
||||
if (! $attachmentsDir || ! $filepath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = rtrim($attachmentsDir, '/').'/'.ltrim($filepath, '/');
|
||||
|
||||
return is_file($candidate) ? $candidate : null;
|
||||
}
|
||||
|
||||
private function createPendingRecord(int $postId, array $row, array $properties): Media
|
||||
{
|
||||
$disk = MediaDisk::name();
|
||||
|
||||
$media = new Media;
|
||||
$media->model_type = Post::class;
|
||||
$media->model_id = $postId;
|
||||
$media->uuid = (string) Str::uuid();
|
||||
$media->collection_name = 'attachments';
|
||||
$media->name = pathinfo($row['filename'] ?? 'attachment', PATHINFO_FILENAME) ?: 'attachment';
|
||||
$media->file_name = $row['filename'] ?: basename($row['filepath'] ?? 'attachment.bin');
|
||||
$media->mime_type = $row['filetype'] ?: null;
|
||||
$media->size = (int) ($row['filesize'] ?? 0);
|
||||
$media->disk = $disk;
|
||||
$media->conversions_disk = $disk;
|
||||
$media->manipulations = [];
|
||||
$media->custom_properties = $properties;
|
||||
$media->generated_conversions = [];
|
||||
$media->responsive_images = [];
|
||||
$media->save();
|
||||
|
||||
return $media;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{--host=127.0.0.1 : 老库主机}
|
||||
{--port=3306 : 老库端口}
|
||||
{--database= : 老库名(必填)}
|
||||
{--username=root : 老库用户名}
|
||||
{--password= : 老库密码}
|
||||
{--prefix=sablog_ : 老表前缀}
|
||||
{--attachments-dir= : 老 attachments 目录(用于同步文件,可选)}
|
||||
{--sync-attachments : 同步附件文件到新存储}
|
||||
{--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();
|
||||
|
||||
$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
|
||||
{
|
||||
$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 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']);
|
||||
Category::query()->updateOrCreate(['id' => $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);
|
||||
$user = User::query()->updateOrCreate(['id' => $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' => date('Y-m-d H:i:s', (int) $row['regdateline']),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$role = $groupMap[(int) $row['groupid']] ?? 'member';
|
||||
if (! $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 = date('Y-m-d H:i:s', (int) $row['dateline']);
|
||||
|
||||
Post::query()->updateOrCreate(['id' => $row['articleid']], [
|
||||
'category_id' => (int) $row['cid'] ?: null,
|
||||
'user_id' => (int) $row['uid'] ?: null,
|
||||
'title' => $row['title'],
|
||||
'slug' => $slug,
|
||||
'excerpt' => $row['description'] ?: null,
|
||||
'content' => $row['content'],
|
||||
'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
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
$this->report['tags'] = $count;
|
||||
}
|
||||
|
||||
private function importComments(): void
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->rows('comments') as $row) {
|
||||
Comment::query()->updateOrCreate(['id' => $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' => date('Y-m-d H:i:s', (int) $row['dateline']),
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->report['comments'] = $count;
|
||||
}
|
||||
|
||||
private function importLinks(): void
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->rows('links') as $row) {
|
||||
Link::query()->updateOrCreate(['id' => $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 importAttachments(): void
|
||||
{
|
||||
if (! $this->db->query('SHOW TABLES LIKE "'.$this->prefix.'attachments"')->fetchColumn()) {
|
||||
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")')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'display_order',
|
||||
'post_count',
|
||||
];
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return route('category.show', $this->slug ?? $this->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_SPAM = 'spam';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'author_name',
|
||||
'author_email',
|
||||
'author_url',
|
||||
'content',
|
||||
'ip',
|
||||
'status',
|
||||
'ai_review',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ai_review' => 'array',
|
||||
];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PUBLISHED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Link extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'url',
|
||||
'note',
|
||||
'display_order',
|
||||
'visible',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'visible' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PluginRecord extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'vendor',
|
||||
'name',
|
||||
'version',
|
||||
'enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function getKeyAttribute(): string
|
||||
{
|
||||
return $this->vendor.'/'.$this->name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Tags\HasTags;
|
||||
|
||||
class Post extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory;
|
||||
use InteractsWithMedia;
|
||||
use HasTags;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'user_id',
|
||||
'title',
|
||||
'slug',
|
||||
'excerpt',
|
||||
'content',
|
||||
'keywords',
|
||||
'status',
|
||||
'is_sticky',
|
||||
'close_comment',
|
||||
'read_password',
|
||||
'views',
|
||||
'comment_count',
|
||||
'meta',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
'is_sticky' => 'boolean',
|
||||
'close_comment' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'published')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, ?string $keyword): Builder
|
||||
{
|
||||
if (! $keyword) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $q) use ($keyword) {
|
||||
$q->where('title', 'like', "%{$keyword}%")
|
||||
->orWhere('content', 'like', "%{$keyword}%")
|
||||
->orWhere('excerpt', 'like', "%{$keyword}%")
|
||||
->orWhere('keywords', 'like', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->width(500)
|
||||
->height(500)
|
||||
->performOnCollections('attachments');
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return route('posts.show', $this->slug ?? $this->id);
|
||||
}
|
||||
|
||||
public function getExcerptOrFallbackAttribute(): string
|
||||
{
|
||||
if ($this->excerpt) {
|
||||
return $this->excerpt;
|
||||
}
|
||||
|
||||
$text = strip_tags($this->content);
|
||||
$text = preg_replace('/\[[^\]]*\]/', '', $text);
|
||||
|
||||
return mb_substr($text, 0, 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public const CACHE_KEY = 'blog.settings';
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$settings = self::allSettings();
|
||||
|
||||
return $settings[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
self::updateOrCreate(['key' => $key], ['value' => is_scalar($value) || $value === null ? $value : json_encode($value)]);
|
||||
self::flushCache();
|
||||
}
|
||||
|
||||
public static function forget(string $key): void
|
||||
{
|
||||
self::where('key', $key)->delete();
|
||||
self::flushCache();
|
||||
}
|
||||
|
||||
public static function allSettings(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, now()->addDay(), function () {
|
||||
return self::query()->pluck('value', 'key')->all();
|
||||
});
|
||||
}
|
||||
|
||||
public static function flushCache(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
public static function seedDefaults(array $defaults): void
|
||||
{
|
||||
foreach ($defaults as $key => $value) {
|
||||
if (! self::query()->where('key', $key)->exists()) {
|
||||
self::query()->create(['key' => $key, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
self::flushCache();
|
||||
}
|
||||
}
|
||||
+50
-2
@@ -2,16 +2,18 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -22,6 +24,13 @@ class User extends Authenticatable
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'legacy_md5',
|
||||
'url',
|
||||
'logincount',
|
||||
'loginip',
|
||||
'regip',
|
||||
'logintime',
|
||||
'lastpost_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -32,6 +41,7 @@ class User extends Authenticatable
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'legacy_md5',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -44,6 +54,44 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'logintime' => 'datetime',
|
||||
'lastpost_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证登录:优先新密码哈希,兼容 sablog 老 MD5 密码。
|
||||
*/
|
||||
public function verifyPassword(string $plain): bool
|
||||
{
|
||||
if ($this->password && Hash::check($plain, $this->password)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->legacy_md5 && md5($plain) === strtolower($this->legacy_md5)) {
|
||||
// 登录成功后自动升级为现代哈希
|
||||
$this->password = Hash::make($plain);
|
||||
$this->legacy_md5 = null;
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class MediaDisk
|
||||
{
|
||||
/**
|
||||
* 实际生效的附件磁盘:
|
||||
* 默认走 S3(uploads);未配置 S3 密钥时回退本地 public,保证开发环境可用。
|
||||
*/
|
||||
public static function name(): string
|
||||
{
|
||||
$configured = config('blog.attachments_disk', 'uploads');
|
||||
|
||||
if ($configured === 'uploads' && empty(config('media.bucket'))) {
|
||||
return 'public';
|
||||
}
|
||||
|
||||
return $configured;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
/**
|
||||
* 生成唯一 slug。中文标题自动回退为 $fallback 或 crc32 形式。
|
||||
*/
|
||||
public static function make(string $string, string $table, string $column = 'slug', ?int $ignoreId = null, ?string $fallback = null): string
|
||||
{
|
||||
$slug = Str::slug($string, '-', 'en');
|
||||
if ($slug === '' || $slug === null) {
|
||||
$slug = $fallback ?? $table.'-'.crc32($string);
|
||||
}
|
||||
|
||||
$base = $slug;
|
||||
$i = 2;
|
||||
|
||||
while (DB::table($table)->where($column, $slug)->where('id', '!=', $ignoreId ?? 0)->exists()) {
|
||||
$slug = $base.'-'.$i++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ return new class extends Migration
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 50);
|
||||
$table->string('slug', 120)->nullable()->unique();
|
||||
$table->tinyInteger('display_order')->default(0);
|
||||
$table->unsignedInteger('post_count')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('title');
|
||||
$table->string('slug', 255)->nullable()->unique();
|
||||
$table->text('excerpt')->nullable();
|
||||
$table->longText('content');
|
||||
$table->string('keywords', 255)->nullable();
|
||||
$table->string('status', 20)->default('published')->index();
|
||||
$table->boolean('is_sticky')->default(false);
|
||||
$table->boolean('close_comment')->default(false);
|
||||
$table->string('read_password', 255)->nullable();
|
||||
$table->unsignedBigInteger('views')->default(0);
|
||||
$table->unsignedInteger('comment_count')->default(0);
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamp('published_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'published_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('posts');
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('author_name', 50);
|
||||
$table->string('author_email', 255)->nullable();
|
||||
$table->string('author_url', 255)->nullable();
|
||||
$table->mediumText('content');
|
||||
$table->string('ip', 64)->nullable();
|
||||
$table->string('status', 20)->default('pending')->index();
|
||||
$table->json('ai_review')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['post_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('comments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 100);
|
||||
$table->string('url', 255);
|
||||
$table->string('note', 255)->nullable();
|
||||
$table->tinyInteger('display_order')->default(0);
|
||||
$table->boolean('visible')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->text('value');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('plugin_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('vendor', 60);
|
||||
$table->string('name', 60);
|
||||
$table->string('version', 40)->nullable();
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['vendor', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plugin_records');
|
||||
Schema::dropIfExists('settings');
|
||||
Schema::dropIfExists('links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('legacy_md5', 32)->nullable()->index();
|
||||
$table->string('url', 255)->nullable();
|
||||
$table->unsignedInteger('logincount')->default(0);
|
||||
$table->string('loginip', 64)->nullable();
|
||||
$table->string('regip', 64)->nullable();
|
||||
$table->timestamp('logintime')->nullable();
|
||||
$table->timestamp('lastpost_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'legacy_md5', 'url', 'logincount', 'loginip', 'regip', 'logintime', 'lastpost_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,24 +2,44 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
foreach (['admin', 'editor', 'member'] as $role) {
|
||||
Role::findOrCreate($role);
|
||||
}
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
$admin = User::query()->firstOrCreate(
|
||||
['email' => 'admin@laralog.test'],
|
||||
[
|
||||
'name' => '管理员',
|
||||
'password' => Hash::make('password'),
|
||||
]
|
||||
);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
Setting::seedDefaults([
|
||||
'site_name' => config('blog.name'),
|
||||
'site_description' => config('blog.description'),
|
||||
'site_icp' => config('blog.icp'),
|
||||
'active_theme' => 'sablog',
|
||||
'comment_audit' => '0',
|
||||
'comment_min_len' => (string) config('blog.comment_min_len'),
|
||||
'comment_max_len' => (string) config('blog.comment_max_len'),
|
||||
'comment_post_space' => (string) config('blog.comment_post_space'),
|
||||
'comment_order' => '1',
|
||||
'rss_num' => (string) config('blog.rss_num'),
|
||||
'seo_default_keywords' => '',
|
||||
'seo_default_description' => '',
|
||||
'site_closed' => '0',
|
||||
'site_closed_note' => '系统升级中...',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user