深挖一个 Laravel 个人博客:从数据模型到生产部署的每一层
这是一个用 Laravel 从零撸的个人博客——文章、动态、评论、点赞、站内搜索、RSS、发文 API、微信同步一应俱全。下面从开发者视角、贴着真实代码,把它的数据建模、发文核心、图片管线、缓存策略、安全加固到生产部署,逐层拆开讲清楚。
源码:https://github.com/ydxred/blog · 在线:https://www.ydxred.com
核心功能一览
在深入代码之前,先看看这个博客到底能干什么——一个人用得舒服、也经得起公网折腾的完整能力集。
1. 博客文章
- Markdown 编辑:基于 EasyMDE 所见即所得;服务端用 league/commonmark 渲染,支持代码块、表格、任务列表等扩展。
- 封面 & 标签:每篇文章可上传封面图、归类多个彩色标签。
- 状态管理:草稿 / 已发布双状态,外加独立的「前台可见」开关。
- 访问统计:基于 IP + Session 自动去重,统计 PV / UV。
- 目录 / 高亮 / 复制:文章详情页自动生成 TOC、代码块语法高亮(highlight.js)、一键复制、图片 Fancybox 预览。
2. 个人动态(说说 / 树洞)
- 类微博短内容:快速发布短篇文字。
- 多图上传:支持 JPG / PNG / GIF / WEBP / HEIC(iPhone 原图)。
- 表情 / 标签 / 加载更多:按需懒加载 emoji-picker,列表无限滚动。
3. 互动与社交
- 评论系统:访客可对文章 / 动态留言;多态关联,一套逻辑通吃。
- 评论审核:后台待审核 / 已通过 / 已拒绝,自动记录评论者 IP 与 User-Agent。
- 点赞:文章与动态点赞,多态关联,访客无登录也能点,按 IP 原子去重。
- 社会化分享:微博 / QQ / 微信扫码 / Twitter。
4. 自动发布 API
- 后台生成 API Token(绑定账号、可一键吊销 / 重置),只存 sha256 哈希。
- REST 风格:
GET/POST/PUT/DELETE /api/articles、POST /api/posts、POST /api/upload、GET /api/tags。 - 发文像调微博 API 一样简单:tags 用名字数组(自动建标签)、封面用 URL / Base64 / 文件、正文远程图自动落地。
- 限速:公开 60 次/分、鉴权 120 次/分;单图最大 50 MB。
- 内置完整中文调用文档 + curl / Python 示例。
5. 微信公众号联动(可选)
- 后台配置 AppID / AppSecret(密文存储),发布文章时可自动同步到公众号草稿箱。
6. 管理后台
- 移动端适配:手机 / 电脑都好用。
- 数据仪表盘:文章、动态、评论、PV / UV 折线图(Chart.js)。
- 标签管理:自定义颜色 / 排序。图片管理:标注未使用图,一键清理孤儿图。
- 访客记录:最近三个月访客 IP / UA / 来源。
- 系统设置:网站名称、「关于我」、API Key、微信配置一键改。
7. 用户系统
- 注册 / 登录 / 找回密码;个人资料(昵称、邮箱、简介、头像);支持彻底注销账号。
8. 前端工程化与 SEO
- 零 CDN 依赖:highlight.js / Fancybox / tocbot / EasyMDE / Chart.js / emoji-picker 全部 npm + Vite 打包,离线 / 内网 / Cloudflare 抖动都不影响。
- 字体本地化:Noto Sans/Serif SC 经
@fontsource进 Vite,按unicode-range拆片懒加载,告别 Google Fonts 跨墙慢。 - 按需加载:首页 ~45 KB(gzip),后台编辑器才加载 EasyMDE。
- SEO:每页
meta description/canonical/og:*/twitter:card完整;文章输出BlogPostingJSON-LD。
9. 性能加速
- HTML 微缓存:OpenResty
fastcgi_cache缓存匿名 GET 30 秒,TTFB 从 ~80 ms → ~9 ms(命中纯 nginx、零 PHP)。 - Brotli 压缩:自编译
ngx_brotli,HTML 比 gzip 再小 ~20%。 - 图片 WebP 协商:
.webpsidecar + nginx 按Accept协商,png/jpg 省 ~60% 带宽。 - Vite 资源 1 年 immutable、OPcache 256 MB + JIT tracing、浏览统计异步写库。
10. 站内搜索 / RSS / Sitemap
- 搜索:
/search全文搜标题 / 摘要 / 正文,LIKE 通配符已转义。 - RSS 2.0:
/rss最近 20 篇;Sitemap:/sitemap.xml收录公开文章与标签页。
11. 图片处理管线
- 域名水印:封面与正文内联图右下角自动打半透明域名水印(动态配图、头像不打)。
- WebP + 尺寸压缩:解码前卡像素上限防「解压炸弹」,生成 WebP、超大边长自动缩放。
- 多来源封面:上传 / URL(SSRF 防护)/ Base64 三选一。
12. 安全加固
- 上传白名单:只认真实图片 magic-bytes,SVG / HTML / 任意扩展名一律拒——防存储型 XSS。
- SSRF 防护:下载远程图校验 IP 非私网 / 回环 / 保留段,禁重定向、限协议。
- CSRF + 限流、PHP 硬化(FPM 禁命令执行族 + 关 FFI)、Nginx 拒敏感文件 + storage/webroot 禁执行 .php。
- 自定义错误页:404(像素动画)/ 419 / 500 / 503 全部自定义,去掉框架默认页指纹。
一、目录结构与分层
app/
├── Models/ Article Post Comment Tag User Setting ArticleVisit
├── Http/
│ ├── Controllers/
│ │ ├── Front/ ArticleController PostController CommentController
│ │ │ LikeController FeedController SearchController ...
│ │ ├── Admin/ Article/Post/Comment/Tag/Setting/Dashboard/Wechat...
│ │ ├── Api/ PublishController
│ │ └── Auth/ Breeze 脚手架
│ ├── Middleware/ ApiTokenMiddleware VerifyCsrfToken AdminMiddleware
│ └── Requests/ 表单校验
├── Services/ ArticlePublisher ImageOptimizer WechatSyncService
└── Http/Resources/ ArticleResource(API 输出塑形)
分层原则:控制器只做编排,业务收进 Service。发文这种复杂逻辑(slug/封面/标签/内联图)后台和 API 都要用,所以抽成 ArticlePublisher,两个入口共用,不重复。
二、数据建模(真实 schema)
2.1 模型与关系
七张核心表。文章模型是重头,直接看代码:
class Article extends Model
{
use HasFactory, SoftDeletes;
protected $casts = [
'published_at' => 'datetime',
'is_visible' => 'boolean',
];
public function tags() { return $this->belongsToMany(Tag::class); }
public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
public function scopePublished($q) {
return $q->where('status', 'published')->whereNotNull('published_at');
}
// 阅读时长:正文去标签后按 400 字/分钟估算,最少 1 分钟
public function getReadTimeAttribute(): int {
return max(1, (int) ceil(mb_strlen(strip_tags($this->content)) / 400));
}
}
read_time 是访问器(accessor),不存库、每次访问算——列表页直接 $article->read_time 就有值。
2.2 多态关联:一套评论/点赞通吃两种目标
评论和点赞都要能挂「文章」或「动态」。用多态最干净——Comment 表有 commentable_type(存模型类名)+ commentable_id:
class Comment extends Model {
public function commentable() { return $this->morphTo(); }
public function scopeApproved($q) { return $q->where('status', 'approved'); }
}
文章和动态里都写 morphMany(Comment::class, 'commentable'),评论逻辑一份代码服务两种内容。点赞同理(likeable_type/likeable_id),而且这张表是从早期单表重构成多态的。
2.3 动态的 images 用 JSON 存
class Post extends Model {
protected $casts = ['images' => 'array']; // 多图直接 JSON 列
}
动态配图是个 URL 数组,不单开一张图片表,array cast 让它读写时自动 JSON 编解码。
2.4 权限:role 字段 + isAdmin()
class User extends Authenticatable {
protected $hidden = ['password', 'remember_token', 'api_token']; // api_token 永不序列化输出
protected $casts = ['password' => 'hashed']; // 自动 bcrypt
public function isAdmin(): bool { return $this->role === 'admin'; }
}
api_token 进 $hidden,任何 toArray()/toJson() 都不会把它带出去。
三、发文核心:ArticlePublisher
后台和 API 发文都走它。create() 的编排:
public function create(array $payload): Article {
$slug = $this->resolveSlug($payload['slug'] ?? null, $payload['title']);
$coverPath = $this->resolveCoverImage($payload); // 文件/URL/Base64
$content = $payload['content'];
if ($payload['fetch_remote_images'] ?? true) {
$content = $this->fetchRemoteImages($content); // 外链图下载落地
}
$excerpt = $this->resolveExcerpt($payload['excerpt'] ?? null, $content);
$article = Article::create([...]);
$article->tags()->attach($this->resolveTags($payload['tags'] ?? null));
return $article->fresh('tags');
}
3.1 中文标题自动转拼音 slug
protected function resolveSlug(?string $slug, string $title, ?int $ignoreId = null): string {
if ($slug) {
$base = Str::slug($slug);
} elseif (preg_match('/\p{Han}/u', $title)) { // 含汉字
$base = Str::slug(Pinyin::sentence($title)); // "我的 API 文章" → "wo-de-api-wen-zhang"
} else {
$base = Str::slug($title);
}
// 唯一性:重复自增 -2/-3…,检查 withTrashed() 含软删的,更新时 ignoreId 排除自己
$finalSlug = $base; $i = 1;
while (Article::withTrashed()->where('slug', $finalSlug)
->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))->exists()) {
$finalSlug = $base . '-' . (++$i);
}
return $finalSlug;
}
唯一性检查带 withTrashed()——软删的文章 slug 也算占用;删除时又会把旧 slug 改成 xxx_deleted_<ts> 释放出来。
3.2 正文外链图自动下载落地
public function fetchRemoteImages(string $content): string {
return preg_replace_callback(
'/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)([^)]*)\)/u',
function ($m) {
if (parse_url($m[2], PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST))
return $m[0]; // 本站图不动
$path = $this->downloadImage($m[2], 'articles/inline');
return $path ? "![{$m[1]}](" . Storage::disk('public')->url($path) . ")" : $m[0];
}, $content);
}
正则扫 Markdown 的外链图,非本站的逐个下载替换。从此原图站防盗链/挂掉都不影响。
3.3 SSRF 防护(下载远程图的安全闸)
protected function isSafeRemoteUrl(string $url): bool {
$parts = parse_url($url);
if (!in_array(strtolower($parts['scheme'] ?? ''), ['http','https'], true)) return false;
// 解析出所有 IP(v4+v6),任一落在私网/回环/保留段就拒
foreach ($this->resolveIps($parts['host']) as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))
return false;
}
return true;
}
下载时还 CURLOPT_FOLLOWLOCATION=false + 限定协议,防止 http://x/redirect → http://169.254.169.254/(云元数据)这类绕过。
3.4 标签解析:名称/ID 混用、不存在自动建
foreach ($tags as $tag) {
if (is_numeric($tag)) { // 数字 → 当 ID(存在才挂)
if (Tag::whereKey((int)$tag)->exists()) $ids[] = (int)$tag;
} else { // 字符串 → 查名/slug,查不到就建
$existing = Tag::where('slug', $tagSlug)->orWhere('name', $name)->first();
$ids[] = $existing?->id ?? Tag::create([...])->id;
}
}
"tags": ["技术", "AI", 5] 名称和 ID 混着传都行。
四、图片管线:ImageOptimizer
所有上传图都过它。makeWebpSidecar() 里按路径决定是否打水印:
// 内容图(封面 articles/、图床 uploads/)打域名水印;动态 posts/、头像 avatars/ 不打
if (preg_match('#^(articles|uploads)/#', $relativePath)) {
$this->watermark($relativePath, $disk);
}
// …解码前 getimagesize 卡 4000 万像素上限(防解压炸弹)…
// …生成 foo.jpg.webp…
水印实现(FreeType 画半透明域名 + 阴影,右下角):
$size = max(11, (int) round($w * 0.026)); // 字号随图宽
$x = $w - $textWidth - $margin; $y = $h - $margin; // 右下角
$shadow = imagecolorallocatealpha($img, 0,0,0, 75); // 半透明黑阴影(浅色图也清晰)
$white = imagecolorallocatealpha($img, 255,255,255, 50);
imagettftext($img, $size, 0, $x+1, $y+1, $shadow, $font, $text);
imagettftext($img, $size, 0, $x, $y, $white, $font, $text);
水印在 WebP 生成前打,所以原图和 WebP 都带。这里有个坑:resolveCoverImage 曾对 URL/Base64 封面重复调 makeWebpSidecar,导致封面被打两次水印——修法是让 URL/Base64 分支只在内部调一次。
五、点赞:IP 维度的原子切换
无需登录,按 IP 记一次赞。核心是并发下计数不串:
$deleted = DB::table('likes')->where('likeable_type', $modelClass)
->where('likeable_id', $id)->where('ip_address', $ip)->delete();
if ($deleted) { // 删掉了 → 取消赞
$model->decrement('likes_count'); $liked = false;
} else { // 没删到 → 之前没赞过 → 点赞
$inserted = DB::table('likes')->insertOrIgnore([...]);
if ($inserted) $model->increment('likes_count');
$liked = true;
}
用 delete() 的返回受影响行数判断之前赞没赞过,配 insertOrIgnore(靠唯一索引挡重复),避免”先查再写”的竞态。目标还先 published()->visible()->findOrFail(),不能给草稿/隐藏内容点赞。
六、评论:多态 + 审核 + 限流
Route::post('/article/{article}/comment', [CommentController::class, 'storeArticleComment'])
->middleware('throttle:10,1'); // 每分钟 10 条
评论默认 status=pending,后台审核通过才展示(scopeApproved);存 IP/UA 便于反垃圾;前台只查 approvedComments()。
七、搜索:LIKE + 通配符转义
$term = str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $q); // 转义,防注入/误当通配符
$like = '%'.$term.'%';
Article::published()->visible()
->where(fn($q) => $q->where('title','like',$like)
->orWhere('excerpt','like',$like)->orWhere('content','like',$like))
->latest('published_at')->paginate(10);
visible() 保证游客搜不到隐藏文章。
八、RSS / Sitemap:故意不用 scopeVisible
// 喂爬虫的东西必须用显式 is_visible=true,
// 不能用 scopeVisible(那个对已登录管理员会放行隐藏文章)
Article::where('status','published')->whereNotNull('published_at')
->where('is_visible', true)->latest('published_at')->get();
这是个容易忽略的点:scopeVisible 对管理员放行隐藏文章,但 sitemap/RSS 是给搜索引擎的,必须只收真正公开的,所以这里显式写 is_visible=true。
九、发文 API 与鉴权
class ApiTokenMiddleware {
public function handle(Request $request, Closure $next): Response {
$token = $request->header('Authorization'); // 只认 header,不收 URL/body(防日志泄露)
if (!$token) return response()->json(['message'=>'Unauthorized'], 401);
if (str_starts_with($token, 'Bearer ')) $token = substr($token, 7);
$user = User::where('api_token', hash('sha256', $token))->first(); // 存的是哈希
if (!$user) return response()->json(['message'=>'Invalid API Token'], 401);
auth()->login($user);
return $next($request);
}
}
Token 生成时给 60 位随机串、只存 sha256 哈希(丢了只能重生成)。API 统一响应信封 {success, message, data},输出用 ArticleResource 塑形(隐藏内部字段、拼出 url/cover_image_url)。
十、微信公众号同步
class WechatSyncService {
protected function config(): array {
$secret = (string) Setting::get('wechat_app_secret', '');
if ($secret !== '') $secret = Crypt::decryptString($secret); // 密文存、用时解
return ['app_id' => Setting::get('wechat_app_id',''), 'secret' => $secret, ...];
}
public function pushArticleToDraft(Article $a): array {
$client = (new Application($this->config()))->getClient();
$thumb = $this->uploadImage($a->cover_image, $client); // 先传封面拿 media_id
$client->postJson('cgi-bin/draft/add', ['articles' => [[...]]]);
$a->wechat_media_id = $result['media_id'];
$a->saveQuietly(); // 静默保存,防再次触发发布钩子递归
}
}
凭据从后台设置读(不是 env——否则 config:cache 后改不了),AppSecret 用 Crypt 密文存储。触发点在 Article 模型的 booted() 钩子:
static::saved(function (Article $article) {
if ($article->status !== 'published' || !empty($article->wechat_media_id)) return;
if (!$article->wasRecentlyCreated && !$article->wasChanged('status')) return; // 只在"变为已发布"时
if (Setting::get('wechat_auto_sync','0') !== '1') return;
dispatch(fn() => $service->pushArticleToDraft(...))->afterResponse(); // 响应后异步,不阻塞
});
用 wasChanged('status') 精确判断”新建即发布 / 草稿转发布”,点赞、切展示等其它保存不误触发;dispatchAfterResponse 让推送在响应返回后跑,用户不用等微信 API。
十一、前端工程
Alpine + Vite + Tailwind,JS 按页拆模块 @vite 按需加载(app.js/article.js/moments.js/likes.js/admin-editor.js/admin-chart.js)。
CSRF:前端不用 axios 自动读 cookie,而是 fetch + <meta name="csrf-token">:
const csrf = document.querySelector('meta[name="csrf-token"]').content;
fetch(url, { method:'POST', headers:{ 'X-CSRF-TOKEN': csrf, Accept:'application/json' }});
Alpine 时序坑:app.js 若立刻 Alpine.start(),页面级脚本(moments.js 定义的 window.postForm)可能还没加载 → x-data 求值时函数未定义。修法:
if (document.readyState === 'complete') Alpine.start();
else document.addEventListener('DOMContentLoaded', () => Alpine.start());
字体本地化:中文 Noto Sans/Serif SC 用 @fontsource 打进构建、按 unicode-range 懒加载,不走 Google Fonts。
十二、性能:Nginx 微缓存(附真实 skip 逻辑)
set $skip_cache 0;
if ($request_method != GET) { set $skip_cache 1; }
if ($http_authorization) { set $skip_cache 1; } # 带 token 的鉴权请求不缓存
if ($request_uri ~* "^/api/") { set $skip_cache 1; }
if ($http_cookie ~* "ydxred_session") { set $skip_cache 1; } # 带会话的不缓存
if ($request_uri ~* "/admin|/login|/like/") { set $skip_cache 1; }
fastcgi_cache microcache;
fastcgi_cache_valid 200 30s;
if ($skip_cache = 0) { more_clear_headers 'Set-Cookie'; } # 仅匿名可缓存响应剥 Cookie
匿名 GET 缓存 30s,TTFB 从几十毫秒降到个位数。关键教训:剥 Set-Cookie 只能在 skip_cache=0(匿名可缓存)时做——否则会把登录用户的会话 Cookie 也剥了,全站 419;缓存键也必须排除 Authorization,否则私有 API 响应被缓存后无 token 也能命中(越权)。
再叠 WebP 协商、OPcache + tracing JIT(改码 2 秒生效)、Cloudflare 边缘缓存。
十三、安全加固(纵深)
- 上传白名单:
guessImageExt只认位图 magic-bytes,SVG/HTML/任意扩展名一律null拒;saveBase64Image不信 data-URI 声明的类型,一律按解码后真实字节判——防存储型 XSS - SSRF:上面的
isSafeRemoteUrl - PHP 硬化:FPM 层
disable_functions禁命令执行族 + 关 FFI;CLI 不受限,artisan/composer 照常 - Nginx:
.env/.git/vendor拒;location ~ \.php$ { return 404; }只执行 index.php——webshell 落地也跑不起来;storage 目录同样禁 .php - 去框架指纹:CSRF cookie 改名、自定义 419/500 页去掉 “Page Expired”、屏蔽
/sanctum/csrf-cookie
一句实话:
disable_functions不是沙箱,挡不住内存破坏类绕过。真正防线是先别让人拿到代码执行(堵上传/注入/文件访问)+ PHP/内核保持最新补丁。
十四、安装与部署
本地开发
git clone https://github.com/ydxred/blog.git blog && cd blog
composer install && npm install
cp .env.example .env && php artisan key:generate
# 配 .env:DB_*、REDIS_*、APP_URL、MAIL_*
php artisan migrate
php artisan storage:link # public/storage → storage/app/public,图片才能访问
npm run build
php artisan serve
建首个管理员(tinker):User::create([... 'role'=>'admin'])。
生产部署(OpenResty + PHP-FPM + MariaDB + Redis + Cloudflare)
Nginx 骨架:
server {
root /var/www/blog/public; index index.php;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ ^/index\.php {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
# …微缓存 skip 逻辑见上…
}
location ~ \.php$ { return 404; } # 只跑 index.php
location ~ /\. { deny all; } # .env/.git
location ~* /(vendor|storage|bootstrap)/.*\.php$ { deny all; }
location ~* \.(jpg|png|webp|css|js|woff2)$ { expires 1y; access_log off; }
}
部署流程:
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
# 前端本地 npm run build 后,同步 public/build
PHP-FPM 关键 ini:opcache.enable=1、opcache.jit=tracing、opcache.validate_timestamps=On(生产也开,配 revalidate_freq=2,改码 2 秒生效不用 reload)。
部署纪律(血泪):
- 改路由必
route:cache,否则不生效 - composer 增删包别用
--no-scripts——漏package:discover,被删包的 ServiceProvider 残留在bootstrap/cache/packages.php→ 全站 500;删完清bootstrap/cache重建 - 改 Blade 要
php -l真编译产物(view:cache说 success 不算数) - storage 按年月分子目录存图,父目录属主不对
www-data就建不了子目录 → 上传 500(chown -R www-data storage) - 升级 PHP 用
--force-confold保住改过的 php.ini - Cloudflare 缓存静态资源,改了源站静态文件要面板 Purge
十五、踩过的坑(复盘)
| 坑 | 根因 | 修法 |
|---|---|---|
| 全站登录 419 | 微缓存无条件剥 Set-Cookie,把会话也剥了 | 只在 skip_cache=0 匿名响应剥 |
| /api 越权泄露 | 缓存键不含 Authorization | 带 token / /api 一律绕过缓存 |
| 封面双水印 | resolveCoverImage 重复调 webp 生成 | URL/Base64 分支只在内部调一次 |
| composer 删包后 500 | --no-scripts 漏 package:discover | 清 bootstrap/cache 重建 |
| 带封面发文 500 | storage 年月目录属主是 root | chown www-data |
十六、结语
一个博客麻雀虽小五脏俱全:多态关系、服务分层、SSRF/XSS 防护、图片管线、缓存串号、部署运维——每块都能踩坑也都能往深做。自己撸一遍最大的收获,是把 Laravel、Nginx、性能、安全这些散点,在一个真实、要对公网负责的项目里串成了线。
完整源码在 GitHub:https://github.com/ydxred/blog 欢迎 star / issue。
—— 完 ——
加载评论中…