M7: 全量测试(25 用例)+ README + 部署文档(nginx/systemd/cron)+ theme:publish 命令
This commit is contained in:
@@ -1,59 +1,119 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# LaraLog
|
||||||
|
|
||||||
<p align="center">
|
基于 **Laravel 12 + Filament 5 + Livewire 4 + Spatie 生态** 的现代化博客系统,从 SaBlog-X 1.6 完整迁移而来:
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
- **脚本迁移**:`php artisan sablog:import` 一键从老库迁入全部数据(文章/分类/评论/标签/友链/用户/附件),保留原始 ID,老 URL 全部 301 兼容
|
||||||
|
- **多主题**:`themes/` 目录即皮肤,后台一键切换/上传 ZIP;内置 sablog 经典风 + modern 简约风两套
|
||||||
|
- **插件系统**:`plugins/` 目录 + 钩子系统(action/filter),ZIP 安装 + 可选远程市场
|
||||||
|
- **内置插件**:SEO & GEO(JSON-LD/OG/llms.txt/Markdown 导出)、AI 评论审核 + 内容润色(Workerman 异步)、支付(支付宝/微信/沙箱)、会员(套餐订阅 + 付费文章)
|
||||||
|
- **附件全走 S3**:R2 / COS / OSS 兼容,新上传零落盘,`attachments:sync-s3` 一键同步老附件(大文件 Multipart)
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
## 技术栈
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
| 组件 | 版本 | 说明 |
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|------|------|------|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
| Laravel | ^12.0 | 应用框架 |
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
| Filament | ^5.7 | 后台面板(zh_CN) |
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
| Livewire | ^4.4 | 前台交互 |
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
| Workerman | ^5.2 | 常驻进程:队列消费者 + WebSocket 进度推送 |
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
| yansongda/pay | ^3.7 | 支付宝 / 微信支付 |
|
||||||
|
| spatie 库 | — | permission / medialibrary / sitemap / robots / tags / backup |
|
||||||
|
| league/commonmark | ^2.9 | Markdown 渲染(GFM) |
|
||||||
|
| league/html-to-markdown | ^5.1 | 老 HTML 内容转换 |
|
||||||
|
| MySQL | 8.x | 数据库 |
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
## 快速开始
|
||||||
|
|
||||||
## Learning Laravel
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
# 2. 配置 .env(MySQL 8)
|
||||||
|
DB_DATABASE=laralog
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
# 3. 迁移 + 种子(创建 admin@laralog.test / password)
|
||||||
|
php artisan migrate --seed
|
||||||
|
|
||||||
## Laravel Sponsors
|
# 4. 启动(Herd 或 artisan serve)
|
||||||
|
php artisan serve
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
# 5. 后台:http://laralog.test/admin
|
||||||
|
```
|
||||||
|
|
||||||
### Premium Partners
|
## 迁移老 Sablog
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
```bash
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
# 原始导入(内容保持 HTML,[attach=xx] 渲染时解析)
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
php artisan sablog:import \
|
||||||
- **[64 Robots](https://64robots.com)**
|
--database=sablog --host=127.0.0.1 --port=3306 \
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
--username=root --password=xxx \
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
--attachments-dir=/path/to/old/attachments --sync-attachments
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
# 同时把内容转换为 Markdown([attach=xx] 转为 {{attach:xx}} 令牌)
|
||||||
|
php artisan sablog:import ... --convert-markdown
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
# 事后单独转换 / 同步附件到 S3
|
||||||
|
php artisan content:convert --all
|
||||||
|
S3_ACCESS_KEY=... S3_BUCKET=... S3_ENDPOINT=... php artisan attachments:sync-s3 --legacy-dir=/path/to/old/attachments
|
||||||
|
```
|
||||||
|
|
||||||
## Code of Conduct
|
> 不迁移的过时功能:trackback(引用通告,垃圾来源)、搜索缓存表、验证码(由 AI 审核 + 蜜罐 + 频控取代)、WAP、广告位变量。
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
## 主题
|
||||||
|
|
||||||
## Security Vulnerabilities
|
- 目录:`themes/{name}/`,含 `theme.json`(标题/版本/描述/作者)+ `views/**` + `assets/`
|
||||||
|
- 激活主题的 views 目录前置到全局视图路径:主题覆盖同名视图,缺失自动回退默认视图
|
||||||
|
- 资产开发期由 `/themes/{name}/assets/...` 流式返回;生产可 `php artisan theme:publish`(见 docs/deploy.md)
|
||||||
|
- 后台「主题管理」可激活 / 上传 ZIP / 删除
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
## 插件
|
||||||
|
|
||||||
## License
|
- 目录:`plugins/{vendor}.{name}/`,含 `plugin.json` + `src/ServiceProvider.php`(继承 `App\Blog\Support\PluginServiceProvider`)+ 可选 `database/migrations`、`routes/web.php`、`views/`
|
||||||
|
- 钩子:`addAction(hook, cb)` / `addFilter(hook, cb, priority)`,核心钩子:
|
||||||
|
- `comment.created`(AI 审核)、`payment.paid`(订阅激活/解锁)、`post.rendered`(付费内容过滤)、`seo.structured_data`
|
||||||
|
- 后台「插件管理」:启停 / 上传 ZIP 安装 / 卸载 / 市场拉取(配置 `MARKET_URL` 后可用)
|
||||||
|
- 插件自带迁移由 `migrate` 自动加载(`app/Providers/AppServiceProvider` 注册 plugins/*/database/migrations)
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
## 内容格式
|
||||||
|
|
||||||
|
- 每篇文章有 `content_format`(markdown / html),新文章默认 Markdown
|
||||||
|
- 老数据导入标记为 html 原样保留;`content:convert` 批量转 Markdown
|
||||||
|
- 附件短代码:HTML 内容用 `[attach=1]` / `[img=1]`,Markdown 内容用 `{{attach:1}}` / `{{img:1}}`(`[]` 是 Markdown 保留字符),渲染时统一解析
|
||||||
|
- 付费内容:`[paid]...[/paid]` 短代码,非会员/未解锁访客隐藏(会员插件)
|
||||||
|
|
||||||
|
## 异步处理(Workerman)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan workerman:serve start # 队列消费者 × N + WebSocket :8787
|
||||||
|
php artisan workerman:serve stop
|
||||||
|
```
|
||||||
|
|
||||||
|
- 队列消费者在常驻进程内复用 Laravel 容器,省去每次任务的框架启动开销(LLM 调用主路径)
|
||||||
|
- 任务统一实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)`),Workerman 反序列化执行
|
||||||
|
- 降级路径:`php artisan queue:work` 照常可用(同一队列)
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test # 25 个用例:迁移/老路由 301/主题/插件/会员支付流/AI 审核
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
Blog/ # 前台域模块(Controllers/Jobs/Services/Support/View)
|
||||||
|
Filament/ # 后台资源与页面
|
||||||
|
Console/Commands # sablog:import / attachments:sync-s3 / workerman:serve / content:convert
|
||||||
|
themes/ # 主题(sablog / modern)
|
||||||
|
plugins/ # 内置插件(blog-seo / ai-moderation / payment / membership)
|
||||||
|
docs/deploy.md # 部署指南
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ThemeServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// 激活主题的 views 目录前置到全局视图路径,实现主题覆盖 + 默认兜底
|
// 激活主题的 views 目录前置到全局视图路径,实现主题覆盖 + 默认兜底
|
||||||
$this->prependActiveThemeViews();
|
app(ThemeManager::class)->prependViews();
|
||||||
|
|
||||||
// 侧边栏数据共享:所有前台视图自动获得 $categories/$recentPosts/$hotTags/$links 等
|
// 侧边栏数据共享:所有前台视图自动获得 $categories/$recentPosts/$hotTags/$links 等
|
||||||
View::composer(
|
View::composer(
|
||||||
@@ -26,25 +26,4 @@ class ThemeServiceProvider extends ServiceProvider
|
|||||||
SidebarComposer::class
|
SidebarComposer::class
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function prependActiveThemeViews(): void
|
|
||||||
{
|
|
||||||
$manager = app(ThemeManager::class);
|
|
||||||
|
|
||||||
// 迁移尚未执行(如 migrate:fresh 首轮)时跳过 DB 查询,使用默认主题
|
|
||||||
$active = \Illuminate\Support\Facades\Schema::hasTable('settings')
|
|
||||||
? $manager->active()
|
|
||||||
: config('themes.default');
|
|
||||||
|
|
||||||
$views = $manager->path($active).'/views';
|
|
||||||
|
|
||||||
if (! is_dir($views)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$paths = config('view.paths');
|
|
||||||
array_unshift($paths, $views);
|
|
||||||
config(['view.paths' => $paths]);
|
|
||||||
app('view')->getFinder()->setPaths($paths);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,30 @@ class ThemeManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
Setting::set('active_theme', $theme);
|
Setting::set('active_theme', $theme);
|
||||||
|
$this->prependViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把激活主题的 views 目录前置到全局视图路径。
|
||||||
|
* 在 Provider boot 与运行时切换主题时调用。
|
||||||
|
*/
|
||||||
|
public function prependViews(): void
|
||||||
|
{
|
||||||
|
// 迁移尚未执行(migrate:fresh 首轮)时跳过 DB 查询,使用默认主题
|
||||||
|
$active = \Illuminate\Support\Facades\Schema::hasTable('settings')
|
||||||
|
? $this->active()
|
||||||
|
: config('themes.default');
|
||||||
|
|
||||||
|
$views = $this->path($active).'/views';
|
||||||
|
|
||||||
|
if (! is_dir($views)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = config('view.paths');
|
||||||
|
array_unshift($paths, $views);
|
||||||
|
config(['view.paths' => $paths]);
|
||||||
|
app('view')->getFinder()->setPaths($paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Blog\Support\ThemeManager;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class ThemePublish extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'theme:publish {name? : 主题名(默认全部)}';
|
||||||
|
|
||||||
|
protected $description = '把主题 assets 拷贝到 public/themes 供生产环境直接托管';
|
||||||
|
|
||||||
|
public function handle(ThemeManager $themes): int
|
||||||
|
{
|
||||||
|
$names = $this->argument('name')
|
||||||
|
? [$this->argument('name')]
|
||||||
|
: array_keys($themes->all());
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (! $themes->exists($name)) {
|
||||||
|
$this->warn("跳过不存在的主题:{$name}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $themes->path($name).'/assets';
|
||||||
|
$target = public_path('themes/'.$name);
|
||||||
|
|
||||||
|
if (! is_dir($source)) {
|
||||||
|
$this->warn("主题 {$name} 没有 assets 目录");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
File::deleteDirectory($target);
|
||||||
|
File::copyDirectory($source, $target);
|
||||||
|
$this->info("已发布:{$name} -> public/themes/{$name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
## 环境变量(.env 关键项)
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_URL=https://blog.example.com
|
||||||
|
APP_LOCALE=zh_CN
|
||||||
|
|
||||||
|
# MySQL 8
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_DATABASE=laralog
|
||||||
|
DB_USERNAME=laralog
|
||||||
|
DB_PASSWORD=***
|
||||||
|
|
||||||
|
# S3 兼容附件(R2 / COS / OSS)
|
||||||
|
S3_ACCESS_KEY=xxx
|
||||||
|
S3_SECRET_KEY=xxx
|
||||||
|
S3_REGION=auto # R2 用 auto;COS/OSS 填对应区域
|
||||||
|
S3_BUCKET=laralog
|
||||||
|
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # COS: https://cos.<region>.myqcloud.com / OSS: https://oss-<region>.aliyuncs.com
|
||||||
|
S3_URL=https://media.example.com # 公开访问域名(CDN),留空则用 endpoint+bucket 拼
|
||||||
|
S3_VISIBILITY=public
|
||||||
|
UPLOAD_DISK=uploads
|
||||||
|
|
||||||
|
# LLM(OpenAI 兼容:DeepSeek / 通义 / 自建代理)
|
||||||
|
LLM_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
LLM_API_KEY=sk-xxx
|
||||||
|
LLM_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# Workerman
|
||||||
|
WORKERMAN_QUEUE_WORKERS=2
|
||||||
|
WORKERMAN_WS_ENABLED=true
|
||||||
|
WORKERMAN_WS_PORT=8787
|
||||||
|
|
||||||
|
# 插件/主题市场(留空仅支持本地 ZIP)
|
||||||
|
MARKET_URL=
|
||||||
|
MARKET_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生产迁移顺序
|
||||||
|
|
||||||
|
1. `php artisan migrate --seed`
|
||||||
|
2. 导入老数据:`php artisan sablog:import --database=sablog ... --attachments-dir=... --sync-attachments --convert-markdown`
|
||||||
|
3. 配置 S3 后同步附件:`php artisan attachments:sync-s3 --legacy-dir=...`
|
||||||
|
4. 生成主题资产(可选):`php artisan theme:publish`
|
||||||
|
5. 启动 Workerman + 队列 + 调度器
|
||||||
|
|
||||||
|
## Nginx(前端由 Laravel 接管,老 URL 由应用层 301)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name blog.example.com;
|
||||||
|
|
||||||
|
root /var/www/laralog/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/blog.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/blog.key;
|
||||||
|
|
||||||
|
# 主题资产(生产已 theme:publish 时可交给 Nginx;未发布则走 Laravel 路由)
|
||||||
|
location ~ ^/themes/.+\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ico)$ {
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 附件已全部走 S3,无需本地静态附件目录
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(?!well-known).* { deny all; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 兼容性说明:所有 sablog 老 URL(`/show-12.html`、`/?action=show&id=12`、`rss.php`、`attachment.php` 等)均由 `routes/web.php` 处理为 301,无需在 Nginx 额外配置重写。
|
||||||
|
|
||||||
|
## systemd:Workerman 常驻服务
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/laralog-workerman.service
|
||||||
|
[Unit]
|
||||||
|
Description=LaraLog Workerman (queue + websocket)
|
||||||
|
After=network.target mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/laralog
|
||||||
|
ExecStart=/usr/bin/php artisan workerman:serve start
|
||||||
|
ExecStop=/usr/bin/php artisan workerman:serve stop
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl enable --now laralog-workerman
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调度器(cron)
|
||||||
|
|
||||||
|
```cron
|
||||||
|
* * * * * cd /var/www/laralog && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
调度器内注册的内容:`spatie/laravel-backup` 备份、缓存清理等(按需在 `routes/console.php` 增删)。
|
||||||
|
|
||||||
|
## 队列降级(不使用 Workerman 时)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/laralog-queue.service
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/laralog
|
||||||
|
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3
|
||||||
|
Restart=always
|
||||||
|
```
|
||||||
|
|
||||||
|
## 备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# spatie/laravel-backup(备份 DB + 附件;S3 附件无需本地备份)
|
||||||
|
php artisan backup:run --only-db
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan theme:publish # 把主题 assets 拷贝到 public/themes
|
||||||
|
php artisan workerman:serve status # 查看常驻进程
|
||||||
|
php artisan content:convert --all # 老 HTML 文章批量转 Markdown
|
||||||
|
```
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Plugins\Neatstudio\AiModeration;
|
namespace Plugins\Neatstudio\AiModeration\Jobs;
|
||||||
|
|
||||||
use App\Blog\Jobs\AiJob;
|
use App\Blog\Jobs\AiJob;
|
||||||
use App\Blog\Services\LlmClient;
|
use App\Blog\Services\LlmClient;
|
||||||
use App\Blog\Support\WorkermanBroadcaster;
|
use App\Blog\Support\WorkermanBroadcaster;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class AiModerateCommentJob implements AiJob
|
class AiModerateCommentJob implements AiJob, ShouldQueue
|
||||||
{
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(public int $commentId)
|
public function __construct(public int $commentId)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Plugins\Neatstudio\AiModeration;
|
namespace Plugins\Neatstudio\AiModeration\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
||||||
use App\Blog\Jobs\AiJob;
|
use App\Blog\Jobs\AiJob;
|
||||||
use App\Blog\Services\LlmClient;
|
use App\Blog\Services\LlmClient;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
|
||||||
class AiPolishContentJob implements AiJob
|
class AiPolishContentJob implements AiJob, ShouldQueue
|
||||||
{
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(public int $postId)
|
public function __construct(public int $postId)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* ============ Modern 简约主题 ============ */
|
||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--card: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #767676;
|
||||||
|
--accent: #e74c3c;
|
||||||
|
--border: #eaeaea;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #151515;
|
||||||
|
--card: #1e1e1e;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--accent: #ff6b5e;
|
||||||
|
--border: #2c2c2c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 16px; line-height: 1.8; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { opacity: 0.8; }
|
||||||
|
.m-container { max-width: 720px; margin: 0 auto; padding: 0 20px; }
|
||||||
|
|
||||||
|
.m-header { border-bottom: 1px solid var(--border); background: var(--card); }
|
||||||
|
.m-header .m-container { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; }
|
||||||
|
.m-logo { font-size: 22px; font-weight: 800; letter-spacing: 1px; color: var(--text); }
|
||||||
|
.m-nav { display: flex; gap: 18px; font-size: 14px; align-items: center; }
|
||||||
|
.m-nav a.active { font-weight: 700; }
|
||||||
|
.m-link-btn { background: none; border: none; color: var(--accent); font-size: 14px; cursor: pointer; padding: 0; }
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
|
||||||
|
.main-layout { padding: 36px 20px; }
|
||||||
|
.main-layout .content { max-width: 720px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.post-card, .post-full, .comments-section { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 26px 30px; margin-bottom: 22px; }
|
||||||
|
.post-card.sticky { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.post-title { font-size: 20px; margin-bottom: 8px; line-height: 1.45; }
|
||||||
|
.post-card .post-title a { color: var(--text); }
|
||||||
|
.badge { background: var(--accent); color: #fff; font-size: 11px; padding: 2px 7px; border-radius: 3px; margin-right: 6px; vertical-align: 2px; }
|
||||||
|
.post-meta { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
|
||||||
|
.post-excerpt { color: var(--muted); }
|
||||||
|
.post-footer { margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.read-more { font-weight: 600; }
|
||||||
|
|
||||||
|
.post-full .post-title { font-size: 28px; }
|
||||||
|
.post-content { margin-top: 14px; }
|
||||||
|
.post-content img { max-width: 100%; border-radius: 8px; }
|
||||||
|
.post-content pre { background: #0d1117; color: #c9d1d9; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 14px 0; }
|
||||||
|
.post-content code { background: rgba(128,128,128,.15); padding: 2px 6px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.post-content pre code { background: none; padding: 0; }
|
||||||
|
.post-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--muted); margin: 14px 0; }
|
||||||
|
.post-content h1, .post-content h2, .post-content h3 { margin: 20px 0 8px; }
|
||||||
|
|
||||||
|
.post-cover { margin: -26px -30px 18px; border-radius: 12px 12px 0 0; overflow: hidden; }
|
||||||
|
.post-cover img { width: 100%; display: block; max-height: 380px; object-fit: cover; }
|
||||||
|
|
||||||
|
.tag-link { color: var(--accent); font-size: 14px; margin-right: 8px; }
|
||||||
|
|
||||||
|
.comments-section { margin-top: 8px; }
|
||||||
|
.section-title { font-size: 17px; margin-bottom: 14px; }
|
||||||
|
.comment { padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.comment:last-of-type { border-bottom: none; }
|
||||||
|
.comment-head { font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.comment-time { color: var(--muted); font-size: 13px; }
|
||||||
|
.comment-body { color: var(--text); font-size: 15px; }
|
||||||
|
.comment-form { margin-top: 14px; }
|
||||||
|
.form-row { margin-bottom: 12px; }
|
||||||
|
.form-row label { display: block; font-size: 14px; margin-bottom: 4px; color: var(--muted); }
|
||||||
|
.form-row input, .form-row textarea { width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 15px; font-family: inherit; }
|
||||||
|
.honeypot { position: absolute; left: -9999px; opacity: 0; }
|
||||||
|
.error { color: var(--accent); font-size: 13px; }
|
||||||
|
.btn { background: var(--accent); color: #fff; border: none; padding: 10px 24px; border-radius: 8px; font-size: 15px; cursor: pointer; }
|
||||||
|
.alert { padding: 12px 18px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
|
||||||
|
.alert-success { background: rgba(46,160,67,.12); color: #2ea043; }
|
||||||
|
.alert-error { background: rgba(248,81,73,.12); color: #f85149; }
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 40px 0; }
|
||||||
|
|
||||||
|
.list-title { font-size: 24px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.archive-year { font-size: 16px; margin: 16px 0 6px; }
|
||||||
|
.archive-list { list-style: none; }
|
||||||
|
.archive-posts { margin: 4px 0 12px 22px; }
|
||||||
|
.archive-posts time { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.pagination { margin: 16px 0; }
|
||||||
|
.pagination-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.pagination-links a, .pagination-links .current, .pagination-links .disabled { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; background: var(--card); color: var(--text); }
|
||||||
|
.pagination-links .current { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.pagination-links .disabled { color: var(--muted); }
|
||||||
|
|
||||||
|
.m-footer { border-top: 1px solid var(--border); padding: 26px 0 40px; margin-top: 30px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.tag-cloud.big { line-height: 2.4; }
|
||||||
|
.links-list { list-style: none; }
|
||||||
|
.links-list li { padding: 6px 0; }
|
||||||
|
.link-note { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.auth-layout { display: flex; justify-content: center; padding: 50px 20px; }
|
||||||
|
.auth-box { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 30px; }
|
||||||
|
.auth-alt { margin-top: 14px; font-size: 14px; color: var(--muted); text-align: center; }
|
||||||
|
.search-form { margin-bottom: 16px; display: flex; gap: 8px; }
|
||||||
|
.search-form input { flex: 1; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 15px; }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* ============ Sablog 经典主题 ============ */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #eef2f5 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==) repeat-x top;
|
||||||
|
font-family: Verdana, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: #1c62b0; text-decoration: none; }
|
||||||
|
a:hover { color: #f60; text-decoration: underline; }
|
||||||
|
|
||||||
|
#outmain { width: 960px; margin: 0 auto; }
|
||||||
|
|
||||||
|
#header { padding: 22px 10px 8px; }
|
||||||
|
.logo a { font-size: 30px; font-weight: bold; color: #1c62b0; font-family: Georgia, serif; }
|
||||||
|
.logo .description { color: #888; font-size: 12px; }
|
||||||
|
|
||||||
|
#nav { margin-top: 14px; border-top: 1px solid #d8dfe6; border-bottom: 1px solid #d8dfe6; background: #f7f9fb; }
|
||||||
|
#nav ul { list-style: none; display: flex; flex-wrap: wrap; }
|
||||||
|
#nav li { border-right: 1px solid #d8dfe6; }
|
||||||
|
#nav a { display: block; padding: 7px 18px; color: #333; font-weight: bold; font-size: 13px; }
|
||||||
|
#nav a:hover { background: #1c62b0; color: #fff; text-decoration: none; }
|
||||||
|
#nav .current_page_item a { background: #1c62b0; color: #fff; }
|
||||||
|
|
||||||
|
#page { padding: 14px 10px; display: flex; gap: 18px; align-items: flex-start; }
|
||||||
|
|
||||||
|
#content { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.post-card, .post-full, .comments-section, .auth-box, .profile-box, .links-list {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8dfe6;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.post-card.sticky { border-left: 4px solid #f60; }
|
||||||
|
.post-title { font-size: 17px; margin-bottom: 6px; line-height: 1.4; }
|
||||||
|
.post-card .post-title a { color: #1c62b0; }
|
||||||
|
.badge { background: #f60; color: #fff; font-size: 11px; padding: 1px 5px; margin-right: 5px; }
|
||||||
|
.post-meta { color: #999; font-size: 12px; margin-bottom: 8px; }
|
||||||
|
.post-excerpt { color: #555; margin-bottom: 8px; }
|
||||||
|
.post-footer { font-size: 12px; }
|
||||||
|
.tag-link { color: #1c62b0; margin-right: 8px; }
|
||||||
|
|
||||||
|
.post-full .post-title { font-size: 22px; color: #1c62b0; }
|
||||||
|
.post-content { margin-top: 10px; font-size: 14px; }
|
||||||
|
.post-content img { max-width: 100%; }
|
||||||
|
.post-content pre { background: #f4f4f4; border: 1px solid #ddd; padding: 10px; overflow: auto; margin: 10px 0; }
|
||||||
|
.post-content blockquote { border-left: 4px solid #1c62b0; padding-left: 12px; color: #666; margin: 10px 0; }
|
||||||
|
.post-content h1, .post-content h2, .post-content h3 { margin: 14px 0 6px; }
|
||||||
|
|
||||||
|
.post-cover { margin: -18px -20px 12px; }
|
||||||
|
.post-cover img { width: 100%; display: block; max-height: 320px; object-fit: cover; }
|
||||||
|
|
||||||
|
.comment { padding: 10px 0; border-bottom: 1px dashed #ddd; }
|
||||||
|
.comment-head { font-size: 12px; margin-bottom: 3px; }
|
||||||
|
.comment-time { color: #999; }
|
||||||
|
.comment-body { font-size: 13px; }
|
||||||
|
.comment-form { margin-top: 12px; }
|
||||||
|
.form-row { margin-bottom: 8px; }
|
||||||
|
.form-row label { display: block; font-size: 12px; margin-bottom: 3px; }
|
||||||
|
.form-row input, .form-row textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccc; font-size: 13px; font-family: inherit; }
|
||||||
|
.honeypot { position: absolute; left: -9999px; opacity: 0; }
|
||||||
|
.btn { background: #1c62b0; color: #fff; border: none; padding: 7px 18px; font-size: 13px; cursor: pointer; }
|
||||||
|
.btn:hover { background: #f60; }
|
||||||
|
.error { color: #c00; font-size: 12px; }
|
||||||
|
.alert { padding: 8px 12px; margin-bottom: 12px; border: 1px solid; }
|
||||||
|
.alert-success { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||||
|
.alert-error { background: #fdecea; border-color: #f5c6cb; color: #c62828; }
|
||||||
|
.empty { color: #999; text-align: center; padding: 20px 0; }
|
||||||
|
.list-title { font-size: 18px; margin-bottom: 12px; color: #1c62b0; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar { width: 280px; flex-shrink: 0; }
|
||||||
|
.sidebar .searchbox { background: #fff; border: 1px solid #d8dfe6; padding: 10px; margin-bottom: 14px; }
|
||||||
|
.sidebar .searchbox input[type=search] { width: 180px; padding: 4px; border: 1px solid #ccc; }
|
||||||
|
.sidebar .widget { background: #fff; border: 1px solid #d8dfe6; margin-bottom: 14px; }
|
||||||
|
.sidebar .widget h3 { background: #f7f9fb; border-bottom: 1px solid #d8dfe6; font-size: 13px; padding: 7px 12px; color: #1c62b0; }
|
||||||
|
.sidebar .widget ul { list-style: none; padding: 6px 12px; }
|
||||||
|
.sidebar .widget li { padding: 3px 0; font-size: 12px; border-bottom: 1px dotted #eee; }
|
||||||
|
.sidebar .tagcloud { padding: 8px 12px; }
|
||||||
|
.sidebar .tagcloud a { color: #1c62b0; margin-right: 6px; }
|
||||||
|
|
||||||
|
/* 兜底页面(归档/标签云/友链/评论/登录等使用默认布局) */
|
||||||
|
.main-layout { display: flex; gap: 18px; align-items: flex-start; padding: 14px 10px; }
|
||||||
|
.main-layout .content { flex: 1; min-width: 0; }
|
||||||
|
.main-layout .sidebar { width: 280px; }
|
||||||
|
|
||||||
|
.pagination-links { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
|
||||||
|
.pagination-links a, .pagination-links .current, .pagination-links .disabled { padding: 4px 10px; border: 1px solid #d8dfe6; background: #fff; font-size: 12px; }
|
||||||
|
.pagination-links .current { background: #1c62b0; color: #fff; }
|
||||||
|
|
||||||
|
#footer { clear: both; text-align: center; padding: 14px 0 24px; color: #888; font-size: 12px; }
|
||||||
|
#footer a { color: #1c62b0; }
|
||||||
|
|
||||||
|
.archive-year { font-size: 15px; margin: 12px 0 4px; color: #1c62b0; }
|
||||||
|
.archive-list { list-style: none; }
|
||||||
|
.archive-posts { margin: 4px 0 10px 20px; }
|
||||||
|
|
||||||
|
.links-list { list-style: none; }
|
||||||
|
.links-list li { padding: 5px 0; }
|
||||||
|
.link-note { color: #999; }
|
||||||
|
|
||||||
|
.tag-cloud.big { line-height: 2.4; }
|
||||||
|
|
||||||
|
.auth-layout { display: flex; justify-content: center; padding: 30px 10px; }
|
||||||
|
.auth-box { width: 100%; max-width: 420px; }
|
||||||
|
.auth-alt { margin-top: 12px; font-size: 12px; }
|
||||||
|
.search-form { margin-bottom: 12px; }
|
||||||
|
.search-form input { padding: 5px; border: 1px solid #ccc; width: 220px; }
|
||||||
|
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
.link-btn { background: none; border: none; color: #1c62b0; cursor: pointer; font-size: 13px; padding: 0; font-weight: bold; }
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Blog\Services\LlmClient;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Plugins\Neatstudio\AiModeration\Jobs\AiModerateCommentJob;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AiModerationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed();
|
||||||
|
Setting::set('ai_moderation_enabled', '1');
|
||||||
|
Setting::set('comment_audit', '1'); // 新评论先进入待审核
|
||||||
|
Setting::set('llm_base_url', 'https://example.test/v1');
|
||||||
|
Setting::set('llm_api_key', 'test-key');
|
||||||
|
Setting::set('llm_model', 'test-model');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_comment_created_dispatches_moderation_job(): void
|
||||||
|
{
|
||||||
|
\Illuminate\Support\Facades\Queue::fake();
|
||||||
|
|
||||||
|
$post = Post::create([
|
||||||
|
'title' => '评论审核',
|
||||||
|
'slug' => 'ai-comment',
|
||||||
|
'content' => '正文',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/posts/'.$post->id.'/comments', [
|
||||||
|
'author_name' => '访客',
|
||||||
|
'content' => '这是一条需要审核的评论',
|
||||||
|
]);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Queue::assertPushed(AiModerateCommentJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_moderation_job_approves_comment(): void
|
||||||
|
{
|
||||||
|
$post = Post::create([
|
||||||
|
'title' => '评论审核',
|
||||||
|
'slug' => 'ai-comment-2',
|
||||||
|
'content' => '正文',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$comment = Comment::create([
|
||||||
|
'post_id' => $post->id,
|
||||||
|
'author_name' => '访客',
|
||||||
|
'content' => '正常评论内容',
|
||||||
|
'status' => 'pending',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$llm = $this->mock(LlmClient::class);
|
||||||
|
$llm->shouldReceive('chat')->once()->andReturn('{"verdict":"approved","reason":"正常"}');
|
||||||
|
|
||||||
|
(new AiModerateCommentJob($comment->id))->handle($llm);
|
||||||
|
|
||||||
|
$comment->refresh();
|
||||||
|
$this->assertSame('published', $comment->status);
|
||||||
|
$this->assertStringContainsString('approved', $comment->ai_review['raw'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_moderation_job_rejects_spam(): void
|
||||||
|
{
|
||||||
|
$post = Post::create([
|
||||||
|
'title' => '评论审核',
|
||||||
|
'slug' => 'ai-comment-3',
|
||||||
|
'content' => '正文',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$comment = Comment::create([
|
||||||
|
'post_id' => $post->id,
|
||||||
|
'author_name' => '广告机',
|
||||||
|
'content' => '点击链接赚钱',
|
||||||
|
'status' => 'pending',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$llm = $this->mock(LlmClient::class);
|
||||||
|
$llm->shouldReceive('chat')->once()->andReturn('{"verdict":"spam","reason":"广告"}');
|
||||||
|
|
||||||
|
(new AiModerateCommentJob($comment->id))->handle($llm);
|
||||||
|
|
||||||
|
$comment->refresh();
|
||||||
|
$this->assertSame('spam', $comment->status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Spatie\Tags\Tag;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LegacyRoutesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed();
|
||||||
|
|
||||||
|
$user = User::create(['name' => '作者', 'email' => 'author@test.com', 'password' => bcrypt('x')]);
|
||||||
|
$category = Category::create(['name' => '技术', 'slug' => 'tech']);
|
||||||
|
\Illuminate\Support\Facades\DB::table('posts')->insert([
|
||||||
|
'id' => 12,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => '老文章',
|
||||||
|
'slug' => 'legacy-post',
|
||||||
|
'content' => '内容',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
Comment::create(['post_id' => 12, 'author_name' => '访客', 'content' => '好文', 'status' => 'published', 'created_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rewrite_style_urls_redirect(): void
|
||||||
|
{
|
||||||
|
$this->get('/show-12.html')->assertRedirect('/posts/legacy-post');
|
||||||
|
$this->get('/show-12-1.html')->assertRedirect('/posts/legacy-post');
|
||||||
|
$this->get('/category-1.html')->assertRedirect();
|
||||||
|
$this->get('/archives-'.now()->format('Ymd').'.html')->assertRedirect();
|
||||||
|
$this->get('/tagslist.html')->assertRedirect('/tags');
|
||||||
|
$this->get('/links.shtml')->assertRedirect('/links');
|
||||||
|
$this->get('/login.shtml')->assertRedirect('/login');
|
||||||
|
$this->get('/reg.shtml')->assertRedirect('/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_query_style_urls_redirect(): void
|
||||||
|
{
|
||||||
|
$this->get('/?action=show&id=12')->assertRedirect('/posts/legacy-post');
|
||||||
|
$this->get('/?action=category&cid=1')->assertRedirect();
|
||||||
|
$this->get('/?action=index&setdate='.now()->format('Ym'))->assertRedirect();
|
||||||
|
$this->get('/?action=search&keyword=laravel')->assertRedirect('/search?q=laravel');
|
||||||
|
$this->get('/?action=links')->assertRedirect('/links');
|
||||||
|
$this->get('/?action=archives')->assertRedirect('/archives');
|
||||||
|
$this->get('/?action=comments')->assertRedirect('/comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_trackback_endpoints_are_gone(): void
|
||||||
|
{
|
||||||
|
$this->get('/tburl.php')->assertStatus(410);
|
||||||
|
$this->get('/trackback.php')->assertStatus(410);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stable_paths_work(): void
|
||||||
|
{
|
||||||
|
$this->get('/rss.xml')->assertOk();
|
||||||
|
$this->get('/sitemap.xml')->assertOk();
|
||||||
|
$this->get('/robots.txt')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_records_return_404(): void
|
||||||
|
{
|
||||||
|
$this->get('/show-99999.html')->assertNotFound();
|
||||||
|
$this->get('/?action=show&id=99999')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_legacy_tag_id_mapping(): void
|
||||||
|
{
|
||||||
|
$tag = Tag::create(['name' => 'laravel-tag', 'type' => 'post']);
|
||||||
|
Setting::set('legacy_tags', [1 => $tag->id]);
|
||||||
|
|
||||||
|
$this->get('/?action=tags&id=1')->assertRedirect('/tags/laravel-tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ThemeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed();
|
||||||
|
|
||||||
|
$user = User::create(['name' => '作者', 'email' => 't@test.com', 'password' => bcrypt('x')]);
|
||||||
|
$category = Category::create(['name' => '分类', 'slug' => 'cat']);
|
||||||
|
Post::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => '主题测试',
|
||||||
|
'slug' => 'theme-test',
|
||||||
|
'content' => '内容',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sablog_theme_renders_classic_layout(): void
|
||||||
|
{
|
||||||
|
app(\App\Blog\Support\ThemeManager::class)->activate('sablog');
|
||||||
|
$this->get('/')->assertOk()->assertSee('outmain');
|
||||||
|
$this->get('/posts/theme-test')->assertOk()->assertSee('outmain');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_modern_theme_renders_modern_layout(): void
|
||||||
|
{
|
||||||
|
app(\App\Blog\Support\ThemeManager::class)->activate('modern');
|
||||||
|
$this->get('/')->assertOk()->assertSee('m-header');
|
||||||
|
$this->get('/posts/theme-test')->assertOk()->assertSee('m-header');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_theme_assets_are_served(): void
|
||||||
|
{
|
||||||
|
$this->get('/themes/sablog/assets/style.css')->assertOk()->assertHeader('Content-Type', 'text/css; charset=utf-8');
|
||||||
|
$this->get('/themes/modern/assets/style.css')->assertOk();
|
||||||
|
$this->get('/themes/nonexistent/assets/style.css')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_theme_falls_back_to_default(): void
|
||||||
|
{
|
||||||
|
Setting::set('active_theme', 'does-not-exist');
|
||||||
|
$this->get('/')->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user