前言
前面章节没看过的朋友请先从第一章开始看 。这章主要写文章相关功能。这里很多功能以后还要扩展一下。我这里只实现了部分功能,大家可以先看看。
后端
创建文章迁移文件
php artisan make:migration create_articles_table
创建文章和分类关联迁移文件
php artisan make:migration create_article_tag_table
创建图片表
php artisan make:migration create_images_table
编辑文章迁移文件
Schema::create('articles', function (Blueprint $table) {
$table->id(); // 主键,自增ID
$table->string('title'); // 文章标题
$table->string('slug')->unique(); // 文章别名,URL 友好,如"laravel-tutorial"
$table->longText('content'); // 文章内容,存储 Markdown 或 HTML
$table->text('summary')->nullable(); // 文章摘要,150-300 字,列表展示用
$table->string('cover', 500)->nullable(); // 封面图片 URL,如"storage/covers/xxx.jpg"
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); // 外键,关联用户表
$table->foreignId('category_id')->nullable()->constrained()->onDelete('restrict'); // 外键,关联分类表
$table->enum('status', ['draft', 'published'])->default('draft'); // 文章状态:草稿或已发布
$table->unsignedInteger('view_count')->default(0); // 阅读量统计
$table->unsignedInteger('like_count')->default(0); // 点赞数统计
$table->boolean('is_top')->default(false); // 是否置顶
$table->string('seo_title')->nullable(); // SEO标题
$table->text('seo_description')->nullable(); // SEO描述
$table->text('seo_keywords')->nullable(); // SEO关键词
$table->timestamp('published_at')->nullable(); // 发布时间,发布时设置
$table->timestamps(); // 创建时间和更新时间
$table->index('status'); // 索引,优化按状态查询
$table->index('published_at'); // 索引,优化按发布时间排序
$table->index('is_top'); // 索引,优化置顶查询
});
编辑关联表迁移文件
Schema::create('article_tag', function (Blueprint $table) {
$table->id(); // 主键,自增ID
$table->foreignId('article_id')->constrained()->onDelete('cascade'); // 外键,关联文章表
$table->foreignId('tag_id')->constrained()->onDelete('cascade'); // 外键,关联标签表
$table->unique(['article_id', 'tag_id']); // 联合唯一索引,防止重复关联
});
编辑图片表
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->string('url'); // 图片 URL
$table->string('path'); // 图片存储路径
$table->unsignedBigInteger('article_id')->nullable(); // 关联文章 ID
$table->unsignedBigInteger('user_id'); // 上传者 ID
$table->enum('type', ['cover', 'content']); // 图片类型:封面或内容图片
$table->enum('status', ['temporary', 'permanent'])->default('temporary'); // 状态:临时或正式
$table->timestamps();
$table->foreign('article_id')->references('id')->on('articles')->onDelete('set null');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->index('status');
$table->index('article_id');
});
执行迁移命令
php artisan migrate
创建模型
php artisan make:model Article
编辑模型
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\HasUniqueSlug;
class Article extends Model
{
// 添加 HasFactory 和 HasUniqueSlug 特性
use HasFactory, HasUniqueSlug;
// 定义可批量赋值的字段
protected $fillable = [
'title',
'slug',
'content',
'summary',
'cover',
'user_id',
'category_id',
'status',
'view_count',
'like_count',
'is_top',
'seo_title',
'seo_description',
'seo_keywords',
'published_at',
];
/**
* 定义文章与分类之间的一对多关系(属于某个分类)。
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function category()
{
return $this->belongsTo(Category::class);
}
/**
* 定义文章与标签之间的多对多关系。
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function tags()
{
return $this->belongsToMany(Tag::class);
}
/**
* 定义文章与用户(作者)之间的一对多关系(属于某个用户)。
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}
编辑种子文件database/seeders/DatabaseSeeder.php
// 文章
$articles = [
[
'title' => '深入掌握 PHP 8 新特性:从 JIT 到属性',
'slug' => 'php-8-new-features',
'content' => 'PHP 8 引入了许多激动人心的新特性,包括 JIT 编译器、属性(Attributes)、联合类型等。本文将详细解析这些新特性的原理和用法,通过实际代码示例展示如何在项目中应用它们以提升性能和代码可读性。',
'summary' => '探索 PHP 8 的 JIT、属性和联合类型,提升开发效率。',
'cover' => 'storage/covers/php8.png',
'user_id' => $admin->id,
'category_id' => $category[0]->id,
'status' => 'published',
'view_count' => 0,
'like_count' => 0,
'is_top' => false,
'seo_title' => 'PHP 8 新特性详解',
'seo_description' => '深入了解 PHP 8 的 JIT、属性和联合类型,提升你的开发效率。',
'seo_keywords' => 'PHP 8, JIT, 属性, 联合类型',
'published_at' => now(),
'tags' => ['PHP', 'Web 开发']
],
[
'title' => 'React Hooks 深度剖析:从 useState 到 useEffect',
'slug' => 'react-hooks-deep-dive',
'content' => 'React Hooks 彻底改变了组件开发的模式。本文深入探讨 useState、useEffect 等核心 Hooks 的工作原理和最佳实践,通过实际案例帮助开发者更高效地构建 React 应用。',
'summary' => '深入剖析 React Hooks 的核心原理与使用技巧。',
'cover' => 'storage/covers/react.png',
'user_id' => $admin->id,
'category_id' => $category[0]->id,
'status' => 'published',
'view_count' => 0,
'like_count' => 0,
'is_top' => false,
'seo_title' => 'React Hooks 完全指南',
'seo_description' => '学习 React Hooks 的核心概念,掌握 useState 和 useEffect 的最佳实践。',
'seo_keywords' => 'React, Hooks, useState, useEffect',
'published_at' => now(),
'tags' => ['React', 'JavaScript']
],
[
'title' => 'Kubernetes 入门与实践:构建可扩展的容器化应用',
'slug' => 'kubernetes-for-beginners',
'content' => 'Kubernetes 是容器编排的行业标准。本教程从 Kubernetes 的基本概念入手,逐步介绍如何部署 Pod、管理 Service 和使用 Helm 进行应用管理,帮助初学者快速上手并构建可扩展的容器化应用。',
'summary' => '面向初学者的 Kubernetes 教程,涵盖部署和管理容器化应用的核心技能。',
'cover' => 'storage/covers/kubernetes.png',
'user_id' => $admin->id,
'category_id' => $category[2]->id,
'status' => 'published',
'view_count' => 0,
'like_count' => 0,
'is_top' => false,
'seo_title' => 'Kubernetes 入门教程',
'seo_description' => '学习 Kubernetes 的核心概念,快速部署和管理容器化应用。',
'seo_keywords' => 'Kubernetes, 容器化, DevOps',
'published_at' => now(),
'tags' => ['Kubernetes', 'DevOps']
],
];
foreach ($articles as $articleData) {
$tagsToSync = $articleData['tags'];
unset($articleData['tags']);
$article = Article::create($articleData);
$tagIds = Tag::whereIn('name', $tagsToSync)->pluck('id');
$article->tags()->sync($tagIds);
}
运行种子文件命令
php artisan migrate:fresh
php artisan db:seed
修改traits
/**
* 在模型创建或更新时自动生成唯一的 slug
*
* @return void
*/
protected static function bootHasUniqueSlug()
{
// 监听模型创建事件
static::creating(function (Model $model) {
$name = $model->name ?? $model->title;
if ($name) {
$model->slug = self::generateUniqueSlug($name, null);
}
});
// 监听模型更新事件
static::updating(function (Model $model) {
// 只有当 name 或 title 字段被修改时才重新生成 slug
if ($model->isDirty('name') || $model->isDirty('title')) {
$name = $model->name ?? $model->title;
if ($name) {
$model->slug = self::generateUniqueSlug($name, $model->id);
}
}
});
}
创建控制器
php artisan make:controller ArticleController
编辑控制器
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Article;
use App\Models\Tag;
use App\Models\Image;
use Illuminate\Support\Str;
class ArticleController extends Controller
{
/**
* 文章列表
*/
public function index(Request $request)
{
$validated = $request->validate([
'per_page' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
'category' => 'nullable|string|max:50',
'tags' => 'nullable|string',
'keyword' => 'nullable|string|max:100',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
], [
'per_page.integer' => '每页数量必须为整数',
'per_page.min' => '每页数量至少为1',
'per_page.max' => '每页数量最多为50',
'page.integer' => '页码必须为整数',
'page.min' => '页码至少为1',
'category.max' => '分类名称或别名不能超过50个字符',
'keyword.max' => '搜索关键词不能超过100个字符',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
]);
$perPage = $validated['per_page'] ?? 10;
$page = $validated['page'] ?? 1;
$query = Article::with(['category:id,name,slug', 'tags:id,name,slug'])
->select([
'id', 'title', 'slug', 'summary', 'cover', 'user_id', 'category_id',
'status', 'view_count', 'like_count', 'is_top', 'seo_title',
'seo_description', 'seo_keywords', 'published_at', 'created_at'
])
->orderBy('is_top', 'desc')
->orderBy('created_at', 'desc');
if ($categoryId = $validated['category'] ?? null) {
$query->where('category_id', $categoryId);
}
if ($tags = $validated['tags'] ?? null) {
$tags = array_filter(explode(',', $tags));
if ($tags) {
$tagIds = Tag::whereIn('slug', $tags)->pluck('id')->toArray();
if (empty($tagIds)) {
return response()->json([
'data' => [],
'meta' => [
'current_page' => 1,
'per_page' => $perPage,
'total' => 0,
'last_page' => 1,
],
'links' => [
'prev' => null,
'next' => null,
],
'message' => '未找到匹配的标签',
], 200);
}
$query->whereHas('tags', fn($q) => $q->whereIn('tags.id', $tagIds));
}
}
if ($keyword = $validated['keyword'] ?? null) {
$keyword = trim($keyword);
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('summary', 'like', "%{$keyword}%");
});
}
}
if ($status = $validated['status'] ?? null) {
$query->where('status', $status);
}
if (isset($validated['is_top'])) {
$query->where('is_top', $validated['is_top']);
}
$articles = $query->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => $articles->items(),
'pagination' => [
'current_page' => $articles->currentPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
'last_page' => $articles->lastPage(),
],
'links' => [
'prev' => $articles->previousPageUrl(),
'next' => $articles->nextPageUrl(),
],
'message' => $articles->isEmpty() ? '暂无文章' : '获取文章列表成功',
], 200);
}
/**
* 显示详细
*/
public function show(Article $article)
{
$article->load(['category:id,name,slug', 'tags:id,name,slug']);
$article->cover_url = $article->cover ? asset('storage/' . $article->cover) : null;
return response()->json([
'data' => $article,
'message' => '获取文章成功'
], 200);
}
/**
* 创建新文章
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'summary' => 'nullable|string|max:500',
'cover_id' => 'nullable|exists:images,id',
'category_id' => 'required|exists:categories,id',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:500',
], [
'title.required' => '文章标题不能为空',
'title.max' => '文章标题不能超过255个字符',
'content.required' => '文章内容不能为空',
'summary.max' => '摘要不能超过500个字符',
'cover_id.exists' => '封面图片ID无效',
'category_id.required' => '分类ID不能为空',
'category_id.exists' => '分类不存在',
'tags.*.exists' => '标签ID无效',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
'seo_title.max' => 'SEO标题不能超过255个字符',
'seo_description.max' => 'SEO描述不能超过500个字符',
'seo_keywords.max' => 'SEO关键词不能超过500个字符',
]);
try {
$article = Article::create([
'title' => $validated['title'],
'slug' => $this->generateUniqueSlug($validated['title']),
'content' => $validated['content'],
'summary' => $validated['summary'] ?? null,
'cover' => null,
'category_id' => $validated['category_id'],
'user_id' => auth('sanctum')->id(),
'status' => $validated['status'] ?? 'published',
'is_top' => $validated['is_top'] ?? false,
'seo_title' => $validated['seo_title'] ?? null,
'seo_description' => $validated['seo_description'] ?? null,
'seo_keywords' => $validated['seo_keywords'] ?? null,
'published_at' => ($validated['status'] ?? 'published') === 'published' ? now() : null,
]);
if (!empty($validated['tags'])) {
$article->tags()->sync($validated['tags']);
}
// 关联图片
$this->associateImages($article, $validated['cover_id'] ?? null);
$article->load(['category:id,name,slug', 'tags:id,name,slug']);
return response()->json([
'data' => [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'content' => $article->content,
'summary' => $article->summary,
'cover' => $article->cover,
'cover_url' => $article->cover ? asset('storage/' . $article->cover) : null,
'category_id' => $article->category_id,
'user_id' => $article->user_id,
'status' => $article->status,
'is_top' => $article->is_top,
'seo_title' => $article->seo_title,
'seo_description' => $article->seo_description,
'seo_keywords' => $article->seo_keywords,
'published_at' => $article->published_at,
'category' => $article->category,
'tags' => $article->tags,
],
'message' => '创建文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '创建文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 更新文章
*/
public function update(Request $request, Article $article)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'summary' => 'nullable|string|max:500',
'cover_id' => 'nullable|exists:images,id',
'category_id' => 'required|exists:categories,id',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:500',
], [
'title.required' => '文章标题不能为空',
'title.max' => '文章标题不能超过255个字符',
'content.required' => '文章内容不能为空',
'summary.max' => '摘要不能超过500个字符',
'cover_id.exists' => '封面图片ID无效',
'category_id.required' => '分类ID不能为空',
'category_id.exists' => '分类不存在',
'tags.*.exists' => '标签ID无效',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
'seo_title.max' => 'SEO标题不能超过255个字符',
'seo_description.max' => 'SEO描述不能超过500个字符',
'seo_keywords.max' => 'SEO关键词不能超过500个字符',
]);
try {
$updateData = [
'title' => $validated['title'],
'slug' => $this->generateUniqueSlug($validated['title'], $article->id),
'content' => $validated['content'],
'summary' => $validated['summary'] ?? null,
'category_id' => $validated['category_id'],
'status' => $validated['status'] ?? $article->status,
'is_top' => $validated['is_top'] ?? $article->is_top,
'seo_title' => $validated['seo_title'] ?? null,
'seo_description' => $validated['seo_description'] ?? null,
'seo_keywords' => $validated['seo_keywords'] ?? null,
'published_at' => $article->status === 'published' && ($validated['status'] ?? $article->status) === 'published' ? now() : $article->published_at,
];
$article->update($updateData);
if (isset($validated['tags'])) {
$article->tags()->sync($validated['tags']);
}
// 关联图片
$this->associateImages($article, $validated['cover_id'] ?? null);
$article->load(['category:id,name,slug', 'tags:id,name,slug']);
return response()->json([
'data' => [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'content' => $article->content,
'summary' => $article->summary,
'cover' => $article->cover,
'cover_url' => $article->cover ? asset('storage/' . $article->cover) : null,
'category_id' => $article->category_id,
'user_id' => $article->user_id,
'status' => $article->status,
'is_top' => $article->is_top,
'seo_title' => $article->seo_title,
'seo_description' => $article->seo_description,
'seo_keywords' => $article->seo_keywords,
'published_at' => $article->published_at,
'category' => $article->category,
'tags' => $article->tags,
],
'message' => '更新文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '更新文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 删除文章
*/
public function destroy(Article $article)
{
try {
// 删除关联标签
$article->tags()->detach();
// 删除关联图片
Image::where('article_id', $article->id)->delete();
$article->delete();
return response()->json([
'message' => '删除文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '删除文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 批量删除文章
*/
public function destroyBatch(Request $request)
{
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|exists:articles,id',
], [
'ids.required' => '文章ID列表不能为空',
'ids.array' => '文章ID列表必须为数组',
'ids.min' => '文章ID列表不能为空',
'ids.*.integer' => '文章ID必须为整数',
'ids.*.exists' => '文章ID不存在',
]);
try {
$articles = Article::whereIn('id', $validated['ids'])
->where('user_id', auth('sanctum')->id())
->get();
if ($articles->isEmpty()) {
return response()->json([
'message' => '无权删除或文章不存在'
], 403);
}
$deletedCount = 0;
foreach ($articles as $article) {
$article->tags()->detach();
Image::where('article_id', $article->id)->delete();
$article->delete();
$deletedCount++;
}
return response()->json([
'data' => [
'deleted_count' => $deletedCount
],
'message' => '批量删除文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '批量删除文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 关联图片到文章
*/
protected function associateImages(Article $article, $coverId = null)
{
// 处理封面图片
if ($coverId) {
$coverImage = Image::where('id', $coverId)
->where('user_id', auth('sanctum')->id())
->where('type', 'cover')
->where('status', 'temporary')
->first();
if ($coverImage) {
$coverImage->update([
'article_id' => $article->id,
'status' => 'permanent',
]);
$article->cover = $coverImage->path;
}
}
// 处理内容图片
preg_match_all('/<img[^>]+src="([^">]+)"/i', $article->content, $matches);
foreach ($matches[1] as $url) {
$path = str_replace(asset('storage/'), '', $url);
$image = Image::where('path', $path)
->where('user_id', auth('sanctum')->id())
->where('type', 'content')
->where('status', 'temporary')
->first();
if ($image) {
$image->update([
'article_id' => $article->id,
'status' => 'permanent',
]);
}
}
$article->save();
}
/**
* 生成唯一 slug
*/
protected function generateUniqueSlug(string $title, $excludeId = null)
{
$slug = Str::slug($title);
$originalSlug = $slug;
$count = 1;
while (Article::where('slug', $slug)
->where('id', '!=', $excludeId)
->exists()) {
$slug = $originalSlug . '-' . $count++;
}
return $slug;
}
}
编辑app/Http/Controller/UploadController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\Image;
use Intervention\Image\Facades\Image as ImageFacade;
class UploadController extends Controller {
/**
* 上传头像
* @param \Illuminate\Http\Request $request
* @return mixed|\Illuminate\Http\JsonResponse
*/
public function uploadAvatar(Request $request)
{
$request->validate([
'avatar' => 'required|image|mimes: jpeg,png,jpg,gif|max:2048'
]);
try{
$file = $request->file('avatar');
// 生成文件名
$fileName = 'avatar_1_' . time() . '.' . $file->getClientOriginalExtension();
// 保存文件到存储路径
$path = $file->storeAs('avatars', $fileName, 'public');
$user = User::find(1);
if($user->avatar && Storage::disk('public')->exists($user->avatar)) {
// 删除旧头像
Storage::disk('public')->delete($user->avatar);
}
// 更新用户头像路径
$user->update(['avatar' => $path]);
return response()->json([
'data' => [
'avatar_url' => asset('storage/'. $path),
'avatar_path' => $path,
],
'message' => '头像上传成功',
], 200);
}catch (\Exception $e){
return response()->json([
'message' => '头像上传失败' . $e->getMessage()
], 500);
}
}
/**
* 上传文章封面
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function uploadCover(Request $request)
{
$request->validate([
'cover' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048'
], [
'cover.required' => '请上传封面图片',
'cover.image' => '文件必须为图片',
'cover.mimes' => '仅支持 jpeg,png,jpg,gif,webp 格式',
'cover.max' => '图片大小不能超过 2MB',
]);
try{
$file = $request->file('cover');
$fileName = 'cover_' . auth('sanctum')->id() . '_' . time() . '.' . '.webp';
$path = 'covers/' . date('Y/m') . '/' . $fileName;
// 压缩并转为 WebP
$image = ImageFacade::make($file)
->resize(1200, null, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})
->encode('webp', 80);
Storage::disk('public')->put($path, $image);
// 记录到 images 表
$imageRecord = Image::create([
'url' => asset('storage/' . $path),
'path' => $path,
'user_id' => auth('sanctum')->id(),
'type' => 'cover',
'status' => 'temporary',
]);
return response()->json([
'data' => [
'cover_url' => $imageRecord->url,
'cover_id' => $imageRecord->id,
'cover_path' => $path,
],
'message' => '封面上传成功',
], 200);
} catch (\Exception $e){
return response()->json([
'message' => '封面上传失败' . $e->getMessage()
], 500);
}
}
/**
* 上传内容图片
*/
public function uploadImage(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:2048',
], [
'image.required' => '请上传图片',
'image.image' => '文件必须为图片',
'image.mimes' => '仅支持 jpeg,png,jpg,gif,webp 格式',
'image.max' => '图片大小不能超过 2MB',
]);
try {
$file = $request->file('image');
$fileName = 'image_' . auth('sanctum')->id() . '_' . time() . '.webp';
$path = 'images/' . date('Y/m') . '/' . $fileName;
// 压缩并转为 WebP
$image = ImageFacade::make($file)
->resize(1200, null, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})
->encode('webp', 80);
Storage::disk('public')->put($path, $image);
// 记录到 images 表
$imageRecord = Image::create([
'url' => asset('storage/' . $path),
'path' => $path,
'user_id' => auth('sanctum')->id(),
'type' => 'content',
'status' => 'temporary',
]);
return response()->json([
'data' => [
'image_url' => $imageRecord->url,
'image_id' => $imageRecord->id,
'image_path' => $path,
],
'message' => '图片上传成功',
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '图片上传失败: ' . $e->getMessage()
], 500);
}
}
/**
* 清理未使用的图片
*/
public function cleanUnusedImages(Request $request)
{
try {
// 确保只有授权用户可以清理(可根据需要添加权限控制)
$request->user('sanctum')->id();
// 查询 7 天前的临时图片
$images = Image::where('status', 'temporary')
->where('created_at', '<', now()->subDays(7))
->where('user_id', auth('sanctum')->id()) // 限制为当前用户
->get();
$deletedCount = 0;
foreach ($images as $image) {
Storage::disk('public')->delete($image->path);
$image->delete();
$deletedCount++;
}
return response()->json([
'data' => [
'deleted_count' => $deletedCount,
],
'message' => "已清理 {$deletedCount} 张未使用图片",
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '清理图片失败: ' . $e->getMessage()
], 500);
}
}
}
修改路由routes/api.php
// 文章
Route::prefix('articles')->group(function () {
Route::get('/', [ArticleController::class, 'index']); // 文章列表
Route::get('/{article}', [ArticleController::class, 'show']); // 文章详细
Route::post('/', [ArticleController::class, 'store']); // 创建文章
Route::put('/{article}', [ArticleController::class, 'update']); // 更新文章
Route::delete('/{article}', [ArticleController::class, 'destroy']); // 删除文章
Route::delete('/batch', [ArticleController::class, 'destroyBatch']); // 批量删除文章
});
// 上传
Route::prefix('upload')->group(function() {
Route::post('/avatar', [UploadController::class, 'uploadAvatar']); // 上传头像
Route::post('/cover', [UploadController::class, 'uploadCover']); // 上传封面
Route::post('/image', [UploadController::class, 'uploadImage']); // 上传文章内容图片
});
551

被折叠的 条评论
为什么被折叠?



