claude 3.5 add this ~~
This commit is contained in:
parent
9bf337a493
commit
b6ff81c7f4
81 changed files with 2695 additions and 125 deletions
4
.cursorignore
Normal file
4
.cursorignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
/.idea
|
||||
/.vscode
|
||||
/.fleet
|
||||
|
|
@ -57,3 +57,6 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
|
|||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1
|
||||
SANCTUM_EXPIRATION=null
|
||||
|
|
|
|||
29
app/Console/Commands/GenerateUserToken.php
Normal file
29
app/Console/Commands/GenerateUserToken.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateUserToken extends Command
|
||||
{
|
||||
protected $signature = 'user:token {email}';
|
||||
protected $description = 'Generate API token for a user';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
$this->error("User not found with email: {$email}");
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $user->createToken('cli-token')->plainTextToken;
|
||||
$this->info("Token generated successfully for user: {$user->name}");
|
||||
$this->info("Token: {$token}");
|
||||
}
|
||||
}
|
||||
44
app/Constants/ApiCode.php
Normal file
44
app/Constants/ApiCode.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Constants;
|
||||
|
||||
class ApiCode
|
||||
{
|
||||
public const SUCCESS = 200;
|
||||
public const CREATED = 201;
|
||||
public const ACCEPTED = 202;
|
||||
public const NO_CONTENT = 204;
|
||||
|
||||
public const BAD_REQUEST = 400;
|
||||
public const UNAUTHORIZED = 401;
|
||||
public const FORBIDDEN = 403;
|
||||
public const NOT_FOUND = 404;
|
||||
public const METHOD_NOT_ALLOWED = 405;
|
||||
public const UNPROCESSABLE_ENTITY = 422;
|
||||
public const TOO_MANY_REQUESTS = 429;
|
||||
|
||||
public const SERVER_ERROR = 500;
|
||||
public const SERVICE_UNAVAILABLE = 503;
|
||||
|
||||
public static function getMessage(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
self::SUCCESS => 'Success',
|
||||
self::CREATED => 'Created',
|
||||
self::ACCEPTED => 'Accepted',
|
||||
self::NO_CONTENT => 'No Content',
|
||||
self::BAD_REQUEST => 'Bad Request',
|
||||
self::UNAUTHORIZED => 'Unauthorized',
|
||||
self::FORBIDDEN => 'Forbidden',
|
||||
self::NOT_FOUND => 'Not Found',
|
||||
self::METHOD_NOT_ALLOWED => 'Method Not Allowed',
|
||||
self::UNPROCESSABLE_ENTITY => 'Unprocessable Entity',
|
||||
self::TOO_MANY_REQUESTS => 'Too Many Requests',
|
||||
self::SERVER_ERROR => 'Server Error',
|
||||
self::SERVICE_UNAVAILABLE => 'Service Unavailable',
|
||||
default => 'Unknown Error',
|
||||
};
|
||||
}
|
||||
}
|
||||
18
app/Events/PostCreated.php
Normal file
18
app/Events/PostCreated.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostCreated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Post $post
|
||||
) {}
|
||||
}
|
||||
18
app/Events/PostUpdated.php
Normal file
18
app/Events/PostUpdated.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostUpdated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Post $post
|
||||
) {}
|
||||
}
|
||||
37
app/Exceptions/ApiException.php
Normal file
37
app/Exceptions/ApiException.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ApiException extends Exception
|
||||
{
|
||||
protected $statusCode = 400;
|
||||
protected $errors = [];
|
||||
|
||||
public function __construct(string $message = '', int $code = 0, array $errors = [])
|
||||
{
|
||||
parent::__construct($message, $code);
|
||||
$this->errors = $errors;
|
||||
if ($code) {
|
||||
$this->statusCode = $code;
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'error' => true,
|
||||
'message' => $this->message,
|
||||
];
|
||||
|
||||
if (!empty($this->errors)) {
|
||||
$response['errors'] = $this->errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $this->statusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Throwable;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
|
|
@ -23,8 +31,52 @@ class Handler extends ExceptionHandler
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
//
|
||||
$this->renderable(function (AuthenticationException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthenticated',
|
||||
'message' => 'You must be logged in to access this resource.',
|
||||
], 401);
|
||||
});
|
||||
|
||||
$this->renderable(function (ValidationException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Validation Error',
|
||||
'message' => 'The given data was invalid.',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
});
|
||||
|
||||
$this->renderable(function (NotFoundHttpException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Not Found',
|
||||
'message' => 'The requested resource was not found.',
|
||||
], 404);
|
||||
});
|
||||
|
||||
$this->renderable(function (AuthorizationException $e) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden',
|
||||
'message' => 'You are not authorized to perform this action.',
|
||||
], 403);
|
||||
});
|
||||
|
||||
$this->renderable(function (Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Server Error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
});
|
||||
|
||||
$this->renderable(function (ModelNotFoundException $e) {
|
||||
return response()->error('Resource not found', 404);
|
||||
});
|
||||
|
||||
$this->renderable(function (QueryException $e) {
|
||||
return response()->error('Database error', 500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
app/Http/Controllers/Api/AuthController.php
Normal file
88
app/Http/Controllers/Api/AuthController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
$user = User::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User registered successfully',
|
||||
'user' => new UserResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/auth/login",
|
||||
* summary="User login",
|
||||
* tags={"Authentication"},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"login","password"},
|
||||
* @OA\Property(property="login", type="string", description="Email or account"),
|
||||
* @OA\Property(property="password", type="string", format="password")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Login successful",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="token", type="string"),
|
||||
* @OA\Property(property="user", type="object")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=422,
|
||||
* description="Validation error"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$login = $request->input('login');
|
||||
$password = $request->input('password');
|
||||
|
||||
// 判断登录字段是邮箱还是账号
|
||||
$field = filter_var($login, FILTER_VALIDATE_EMAIL) ? 'email' : 'account';
|
||||
|
||||
$user = User::where($field, $login)->first();
|
||||
|
||||
if (! $user || ! Hash::check($password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => ['The provided credentials are incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'token' => $user->createToken('auth_token')->plainTextToken,
|
||||
'user' => new UserResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(): JsonResponse
|
||||
{
|
||||
auth()->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Successfully logged out',
|
||||
]);
|
||||
}
|
||||
}
|
||||
84
app/Http/Controllers/Api/CategoryController.php
Normal file
84
app/Http/Controllers/Api/CategoryController.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Http\Resources\PostResource;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$categories = Category::withCount('posts')->get();
|
||||
|
||||
return response()->json([
|
||||
'categories' => CategoryResource::collection($categories)
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:categories',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$category = Category::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Category created successfully',
|
||||
'category' => $category
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Category $category): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255|unique:categories,name,' . $category->id,
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Category updated successfully',
|
||||
'category' => $category
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Category $category): JsonResponse
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Category deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
public function posts(Category $category, Request $request): JsonResponse
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$perPage = min($perPage, 100);
|
||||
|
||||
$posts = $category->posts()
|
||||
->with(['author:id,name'])
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'posts' => PostResource::collection($posts),
|
||||
'pagination' => [
|
||||
'total' => $posts->total(),
|
||||
'per_page' => $posts->perPage(),
|
||||
'current_page' => $posts->currentPage(),
|
||||
'last_page' => $posts->lastPage(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/Api/CommentController.php
Normal file
66
app/Http/Controllers/Api/CommentController.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index(Post $post): JsonResponse
|
||||
{
|
||||
$comments = $post->comments()
|
||||
->with('author:id,name')
|
||||
->get()
|
||||
->map(fn($comment) => [
|
||||
'id' => $comment->id,
|
||||
'content' => $comment->content,
|
||||
'author' => $comment->author,
|
||||
'created_at' => $comment->created_at,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'comments' => $comments
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
]);
|
||||
|
||||
$comment = $post->comments()->create([
|
||||
...$validated,
|
||||
'author_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$comment->load('author:id,name');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Comment added successfully',
|
||||
'comment' => [
|
||||
'id' => $comment->id,
|
||||
'content' => $comment->content,
|
||||
'author' => $comment->author,
|
||||
'created_at' => $comment->created_at,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Comment deleted successfully'
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/PostController.php
Normal file
139
app/Http/Controllers/Api/PostController.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Requests\Post\ListPostRequest;
|
||||
use App\Http\Resources\PaginatedResource;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function index(ListPostRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$perPage = min($request->input('per_page', 10), 100);
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
|
||||
$allowedSortFields = ['created_at', 'title', 'views'];
|
||||
$sortBy = in_array($sortBy, $allowedSortFields) ? $sortBy : 'created_at';
|
||||
$sortOrder = in_array($sortOrder, ['asc', 'desc']) ? $sortOrder : 'desc';
|
||||
|
||||
$query = Post::with(['author:id,name', 'category:id,name'])
|
||||
->when($request->authorId, fn($q) => $q->where('author_id', $request->authorId))
|
||||
->when($request->category, fn($q) => $q->whereHas('category', fn($q) =>
|
||||
$q->where('name', $request->category)
|
||||
))
|
||||
->when($request->search, fn($q) => $q->where(fn($q) =>
|
||||
$q->where('title', 'like', "%{$request->search}%")
|
||||
->orWhere('content', 'like', "%{$request->search}%")
|
||||
));
|
||||
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
$posts = $query->paginate($perPage);
|
||||
|
||||
return response()->success([
|
||||
'posts' => new PaginatedResource($posts)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$this->postViewService->recordView($post, $request->ip());
|
||||
|
||||
$post->load(['author:id,name', 'comments.author:id,name']);
|
||||
|
||||
return response()->json([
|
||||
'post' => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'content' => $post->content,
|
||||
'author' => $post->author,
|
||||
'comments' => $post->comments->map(fn($comment) => [
|
||||
'id' => $comment->id,
|
||||
'content' => $comment->content,
|
||||
'author' => $comment->author,
|
||||
'created_at' => $comment->created_at,
|
||||
]),
|
||||
'created_at' => $post->created_at,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$post = $request->user()->posts()->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Post created successfully',
|
||||
'post' => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'content' => $post->content,
|
||||
'author' => [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
],
|
||||
'created_at' => $post->created_at,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'content' => 'sometimes|string',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$post->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Post updated successfully',
|
||||
'post' => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'content' => $post->content,
|
||||
'author' => [
|
||||
'id' => $post->author->id,
|
||||
'name' => $post->author->name,
|
||||
],
|
||||
'updated_at' => $post->updated_at,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Post $post): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $post);
|
||||
|
||||
$post->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Post deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
public function popular(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->postViewService->getPopularPosts(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/Api/SearchController.php
Normal file
56
app/Http/Controllers/Api/SearchController.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\PostResource;
|
||||
use App\Services\SearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SearchService $searchService
|
||||
) {}
|
||||
|
||||
public function posts(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => 'required|string|min:2',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'author_id' => 'nullable|exists:users,id',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$results = $this->searchService->searchPosts(
|
||||
$request->input('q'),
|
||||
$request->only(['category_id', 'author_id']),
|
||||
$perPage
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => PostResource::collection($results),
|
||||
'pagination' => [
|
||||
'total' => $results->total(),
|
||||
'per_page' => $results->perPage(),
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function relatedPosts(Request $request, int $postId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$relatedPosts = $this->searchService->getRelatedPosts($post);
|
||||
|
||||
return response()->json([
|
||||
'data' => PostResource::collection($relatedPosts),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Api/StatisticsController.php
Normal file
33
app/Http/Controllers/Api/StatisticsController.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\StatisticsService;
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StatisticsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected StatisticsService $statisticsService,
|
||||
protected ActivityLogService $activityLogService
|
||||
) {}
|
||||
|
||||
public function posts(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->statisticsService->getPostStatistics(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function userActivities(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->activityLogService->getUserActivities($request->user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Api/UserController.php
Normal file
68
app/Http/Controllers/Api/UserController.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$users = User::all();
|
||||
|
||||
return response()->json([
|
||||
'users' => $users->map(fn($user) => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'email' => [
|
||||
'sometimes',
|
||||
'email',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
'password' => 'sometimes|string|min:8',
|
||||
'role' => 'sometimes|in:admin,author',
|
||||
]);
|
||||
|
||||
if (isset($validated['password'])) {
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User updated successfully',
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(User $user): JsonResponse
|
||||
{
|
||||
$user->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User deleted successfully'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,53 @@
|
|||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
/**
|
||||
* @OA\Info(
|
||||
* version="1.0.0",
|
||||
* title="Blog API Documentation",
|
||||
* description="Blog system API documentation",
|
||||
* @OA\Contact(
|
||||
* email="admin@example.com"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Server(
|
||||
* url=L5_SWAGGER_CONST_HOST,
|
||||
* description="API Server"
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="bearerAuth",
|
||||
* type="http",
|
||||
* scheme="bearer",
|
||||
* bearerFormat="JWT"
|
||||
* )
|
||||
*
|
||||
* @OA\Tag(
|
||||
* name="Authentication",
|
||||
* description="API Endpoints for user authentication"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Posts",
|
||||
* description="API Endpoints for blog posts"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Comments",
|
||||
* description="API Endpoints for post comments"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Categories",
|
||||
* description="API Endpoints for post categories"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Users",
|
||||
* description="API Endpoints for user management"
|
||||
* )
|
||||
* @OA\Tag(
|
||||
* name="Statistics",
|
||||
* description="API Endpoints for statistics"
|
||||
* )
|
||||
*/
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ class Kernel extends HttpKernel
|
|||
],
|
||||
|
||||
'api' => [
|
||||
\App\Http\Middleware\FormatApiResponse::class,
|
||||
\App\Http\Middleware\ApiVersion::class,
|
||||
\App\Http\Middleware\ApiRateLimiter::class,
|
||||
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
|
|
|
|||
23
app/Http/Middleware/AdminMiddleware.php
Normal file
23
app/Http/Middleware/AdminMiddleware.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->isAdmin()) {
|
||||
return response()->json([
|
||||
'message' => 'Unauthorized. Admin access required.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
36
app/Http/Middleware/ApiRateLimiter.php
Normal file
36
app/Http/Middleware/ApiRateLimiter.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiRateLimiter
|
||||
{
|
||||
public function __construct(
|
||||
protected RateLimiter $limiter
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $request->user()?->id ?? $request->ip();
|
||||
|
||||
if ($this->limiter->tooManyAttempts($key, 60)) { // 60次/分钟
|
||||
return response()->json([
|
||||
'error' => 'Too Many Attempts',
|
||||
'message' => 'Please try again later.',
|
||||
], 429);
|
||||
}
|
||||
|
||||
$this->limiter->hit($key);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
return $response->header('X-RateLimit-Limit', 60)
|
||||
->header('X-RateLimit-Remaining', $this->limiter->remaining($key, 60));
|
||||
}
|
||||
}
|
||||
22
app/Http/Middleware/ApiVersion.php
Normal file
22
app/Http/Middleware/ApiVersion.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiVersion
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// 添加 API 版本响应头
|
||||
$response->headers->set('X-API-Version', 'v1');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/FormatApiResponse.php
Normal file
48
app/Http/Middleware/FormatApiResponse.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormatApiResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if ($response instanceof JsonResponse && !$this->isSwaggerRequest($request)) {
|
||||
$data = $response->getData(true);
|
||||
|
||||
$formatted = [
|
||||
'success' => $response->getStatusCode() < 400,
|
||||
'message' => $data['message'] ?? ($response->getStatusCode() < 400 ? 'Success' : 'Error'),
|
||||
];
|
||||
|
||||
if (isset($data['data'])) {
|
||||
$formatted['data'] = $data['data'];
|
||||
}
|
||||
|
||||
if (isset($data['errors'])) {
|
||||
$formatted['errors'] = $data['errors'];
|
||||
}
|
||||
|
||||
if (isset($data['pagination'])) {
|
||||
$formatted['pagination'] = $data['pagination'];
|
||||
}
|
||||
|
||||
$response->setData($formatted);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function isSwaggerRequest(Request $request): bool
|
||||
{
|
||||
return str_starts_with($request->path(), 'api/documentation');
|
||||
}
|
||||
}
|
||||
31
app/Http/Middleware/LogApiRequests.php
Normal file
31
app/Http/Middleware/LogApiRequests.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LogApiRequests
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (app()->environment('production')) {
|
||||
Log::channel('api')->info('API Request', [
|
||||
'method' => $request->method(),
|
||||
'url' => $request->fullUrl(),
|
||||
'user' => $request->user()?->id,
|
||||
'ip' => $request->ip(),
|
||||
'status' => $response->status(),
|
||||
'duration' => defined('LARAVEL_START') ? round((microtime(true) - LARAVEL_START) * 1000, 2) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Auth/LoginRequest.php
Normal file
23
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'login' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Auth/RegisterRequest.php
Normal file
26
app/Http/Requests/Auth/RegisterRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->isAdmin();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'account' => 'required|string|max:255|unique:users,account',
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|in:admin,author',
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Post/ListPostRequest.php
Normal file
28
app/Http/Requests/Post/ListPostRequest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Post;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ListPostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'authorId' => 'nullable|exists:users,id',
|
||||
'category' => 'nullable|exists:categories,name',
|
||||
'search' => 'nullable|string|min:2',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'sort_by' => 'nullable|in:created_at,title,views',
|
||||
'sort_order' => 'nullable|in:asc,desc',
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/Post/StorePostRequest.php
Normal file
25
app/Http/Requests/Post/StorePostRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Post;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/Post/UpdatePostRequest.php
Normal file
25
app/Http/Requests/Post/UpdatePostRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Post;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'content' => 'sometimes|string',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Http/Resources/ApiResource.php
Normal file
50
app/Http/Resources/ApiResource.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApiResource extends JsonResource
|
||||
{
|
||||
public static $wrap = null;
|
||||
|
||||
protected bool $success = true;
|
||||
protected string $message = 'Success';
|
||||
protected ?array $meta = null;
|
||||
|
||||
public function success(bool $success): self
|
||||
{
|
||||
$this->success = $success;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function message(string $message): self
|
||||
{
|
||||
$this->message = $message;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function meta(array $meta): self
|
||||
{
|
||||
$this->meta = $meta;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$response = [
|
||||
'success' => $this->success,
|
||||
'message' => $this->message,
|
||||
'data' => parent::toArray($request),
|
||||
];
|
||||
|
||||
if ($this->meta) {
|
||||
$response['meta'] = $this->meta;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
23
app/Http/Resources/CategoryResource.php
Normal file
23
app/Http/Resources/CategoryResource.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'posts_count' => $this->when(isset($this->posts_count), $this->posts_count),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Resources/CommentResource.php
Normal file
22
app/Http/Resources/CommentResource.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CommentResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'content' => $this->content,
|
||||
'author' => new UserResource($this->whenLoaded('author')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Resources/PaginatedResource.php
Normal file
24
app/Http/Resources/PaginatedResource.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PaginatedResource extends ResourceCollection
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
'pagination' => [
|
||||
'total' => $this->resource->total(),
|
||||
'per_page' => $this->resource->perPage(),
|
||||
'current_page' => $this->resource->currentPage(),
|
||||
'last_page' => $this->resource->lastPage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Resources/PostResource.php
Normal file
27
app/Http/Resources/PostResource.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'summary' => $this->summary,
|
||||
'content' => $this->content,
|
||||
'views' => $this->views,
|
||||
'author' => new UserResource($this->whenLoaded('author')),
|
||||
'category' => new CategoryResource($this->whenLoaded('category')),
|
||||
'comments' => CommentResource::collection($this->whenLoaded('comments')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Resources/UserResource.php
Normal file
23
app/Http/Resources/UserResource.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'role' => $this->role,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Responses/ApiResponse.php
Normal file
53
app/Http/Responses/ApiResponse.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
public static function success($data = null, string $message = 'Success', int $code = 200): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($data instanceof JsonResource) {
|
||||
$response['data'] = $data;
|
||||
return $data->additional($response)->response()->setStatusCode($code);
|
||||
}
|
||||
|
||||
if ($data instanceof LengthAwarePaginator) {
|
||||
$response['data'] = $data->items();
|
||||
$response['pagination'] = [
|
||||
'total' => $data->total(),
|
||||
'per_page' => $data->perPage(),
|
||||
'current_page' => $data->currentPage(),
|
||||
'last_page' => $data->lastPage(),
|
||||
];
|
||||
} else {
|
||||
$response['data'] = $data;
|
||||
}
|
||||
|
||||
return response()->json($response, $code);
|
||||
}
|
||||
|
||||
public static function error(string $message = 'Error', int $code = 400, array $errors = null): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $code);
|
||||
}
|
||||
}
|
||||
22
app/Listeners/ClearPostCache.php
Normal file
22
app/Listeners/ClearPostCache.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\PostCreated;
|
||||
use App\Events\PostUpdated;
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ClearPostCache
|
||||
{
|
||||
public function __construct(
|
||||
protected CacheService $cacheService
|
||||
) {}
|
||||
|
||||
public function handle(PostCreated|PostUpdated $event): void
|
||||
{
|
||||
Cache::tags(['posts', 'search'])->flush();
|
||||
}
|
||||
}
|
||||
35
app/Models/ActivityLog.php
Normal file
35
app/Models/ActivityLog.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ActivityLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'model_type',
|
||||
'model_id',
|
||||
'description',
|
||||
'properties',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'properties' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo('model');
|
||||
}
|
||||
}
|
||||
24
app/Models/Category.php
Normal file
24
app/Models/Category.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
}
|
||||
33
app/Models/Comment.php
Normal file
33
app/Models/Comment.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'content',
|
||||
'post_id',
|
||||
'author_id',
|
||||
];
|
||||
|
||||
protected $dates = ['deleted_at'];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
}
|
||||
}
|
||||
41
app/Models/Post.php
Normal file
41
app/Models/Post.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Post extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'summary',
|
||||
'content',
|
||||
'category_id',
|
||||
'author_id',
|
||||
];
|
||||
|
||||
protected $dates = ['deleted_at'];
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'account',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class, 'author_id');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class, 'author_id');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function isAuthor(): bool
|
||||
{
|
||||
return $this->role === 'author';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
app/Policies/CategoryPolicy.php
Normal file
26
app/Policies/CategoryPolicy.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
|
||||
class CategoryPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function update(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function delete(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
16
app/Policies/CommentPolicy.php
Normal file
16
app/Policies/CommentPolicy.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
|
||||
class CommentPolicy
|
||||
{
|
||||
public function delete(User $user, Comment $comment): bool
|
||||
{
|
||||
return $user->isAdmin() || $comment->author_id === $user->id;
|
||||
}
|
||||
}
|
||||
21
app/Policies/PostPolicy.php
Normal file
21
app/Policies/PostPolicy.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
|
||||
class PostPolicy
|
||||
{
|
||||
public function update(User $user, Post $post): bool
|
||||
{
|
||||
return $user->isAdmin() || $post->author_id === $user->id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Post $post): bool
|
||||
{
|
||||
return $user->isAdmin() || $post->author_id === $user->id;
|
||||
}
|
||||
}
|
||||
35
app/Providers/ApiServiceProvider.php
Normal file
35
app/Providers/ApiServiceProvider.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
|
||||
class ApiServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Response::macro('success', function ($data = null, string $message = 'Success', int $code = 200) {
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], $code);
|
||||
});
|
||||
|
||||
Response::macro('error', function (string $message = 'Error', int $code = 400, $errors = null) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return Response::json($response, $code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,25 +2,24 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
// use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
use App\Models\Comment;
|
||||
use App\Policies\CategoryPolicy;
|
||||
use App\Policies\PostPolicy;
|
||||
use App\Policies\CommentPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The model to policy mappings for the application.
|
||||
*
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
//
|
||||
Post::class => PostPolicy::class,
|
||||
Comment::class => CommentPolicy::class,
|
||||
Category::class => CategoryPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
$this->registerPolicies();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,35 +2,27 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use App\Events\PostCreated;
|
||||
use App\Events\PostUpdated;
|
||||
use App\Listeners\ClearPostCache;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event to listener mappings for the application.
|
||||
*
|
||||
* @var array<class-string, array<int, class-string>>
|
||||
*/
|
||||
protected $listen = [
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
PostCreated::class => [
|
||||
ClearPostCache::class,
|
||||
],
|
||||
PostUpdated::class => [
|
||||
ClearPostCache::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any events for your application.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if events and listeners should be automatically discovered.
|
||||
*/
|
||||
public function shouldDiscoverEvents(): bool
|
||||
{
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public function boot(): void
|
|||
|
||||
$this->routes(function () {
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->prefix('api/v1')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
|
|
|
|||
45
app/Services/ActivityLogService.php
Normal file
45
app/Services/ActivityLogService.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivityLogService
|
||||
{
|
||||
public function log(
|
||||
User $user,
|
||||
string $action,
|
||||
Model $subject,
|
||||
string $description,
|
||||
array $properties = []
|
||||
): void {
|
||||
ActivityLog::create([
|
||||
'user_id' => $user->id,
|
||||
'action' => $action,
|
||||
'model_type' => get_class($subject),
|
||||
'model_id' => $subject->id,
|
||||
'description' => $description,
|
||||
'properties' => $properties,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUserActivities(User $user, int $limit = 10): array
|
||||
{
|
||||
return ActivityLog::with('subject')
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn($log) => [
|
||||
'action' => $log->action,
|
||||
'description' => $log->description,
|
||||
'created_at' => $log->created_at,
|
||||
'properties' => $log->properties,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
40
app/Services/CacheService.php
Normal file
40
app/Services/CacheService.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CacheService
|
||||
{
|
||||
protected string $prefix;
|
||||
protected int $ttl;
|
||||
|
||||
public function __construct(string $prefix = '', int $ttl = 3600)
|
||||
{
|
||||
$this->prefix = $prefix;
|
||||
$this->ttl = $ttl;
|
||||
}
|
||||
|
||||
public function remember(string $key, callable $callback)
|
||||
{
|
||||
return Cache::remember($this->getCacheKey($key), $this->ttl, $callback);
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
Cache::forget($this->getCacheKey($key));
|
||||
}
|
||||
|
||||
public function tags(array $tags): self
|
||||
{
|
||||
$this->prefix = implode(':', $tags) . ':';
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function getCacheKey(string $key): string
|
||||
{
|
||||
return $this->prefix . $key;
|
||||
}
|
||||
}
|
||||
55
app/Services/PostService.php
Normal file
55
app/Services/PostService.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PostService
|
||||
{
|
||||
public function getPosts(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = Post::with(['author:id,name', 'category:id,name']);
|
||||
|
||||
$query = $this->applyFilters($query, $filters);
|
||||
|
||||
return $query->latest()->paginate(10);
|
||||
}
|
||||
|
||||
public function createPost(User $user, array $data): Post
|
||||
{
|
||||
return $user->posts()->create($data);
|
||||
}
|
||||
|
||||
public function updatePost(Post $post, array $data): Post
|
||||
{
|
||||
$post->update($data);
|
||||
return $post->fresh();
|
||||
}
|
||||
|
||||
protected function applyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
if (!empty($filters['author_id'])) {
|
||||
$query->where('author_id', $filters['author_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['category'])) {
|
||||
$query->whereHas('category', function ($q) use ($filters) {
|
||||
$q->where('name', $filters['category']);
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->where('title', 'like', "%{$filters['search']}%")
|
||||
->orWhere('content', 'like', "%{$filters['search']}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
50
app/Services/PostViewService.php
Normal file
50
app/Services/PostViewService.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PostViewService
|
||||
{
|
||||
protected CacheService $cacheService;
|
||||
|
||||
public function __construct(CacheService $cacheService)
|
||||
{
|
||||
$this->cacheService = $cacheService;
|
||||
}
|
||||
|
||||
public function recordView(Post $post, string $visitorId): void
|
||||
{
|
||||
$cacheKey = "post:{$post->id}:visitor:{$visitorId}";
|
||||
|
||||
if (!Cache::has($cacheKey)) {
|
||||
DB::transaction(function () use ($post) {
|
||||
$post->increment('views');
|
||||
});
|
||||
|
||||
// 设置24小时内同一访客不重复计数
|
||||
Cache::put($cacheKey, true, now()->addHours(24));
|
||||
}
|
||||
}
|
||||
|
||||
public function getPopularPosts(int $limit = 10): array
|
||||
{
|
||||
return $this->cacheService
|
||||
->tags(['posts', 'popular'])
|
||||
->remember('posts:popular', function () use ($limit) {
|
||||
return Post::orderByDesc('views')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn($post) => [
|
||||
'id' => $post->id,
|
||||
'title' => $post->title,
|
||||
'views' => $post->views,
|
||||
])
|
||||
->toArray();
|
||||
});
|
||||
}
|
||||
}
|
||||
54
app/Services/SearchService.php
Normal file
54
app/Services/SearchService.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class SearchService
|
||||
{
|
||||
protected CacheService $cacheService;
|
||||
|
||||
public function __construct(CacheService $cacheService)
|
||||
{
|
||||
$this->cacheService = $cacheService;
|
||||
}
|
||||
|
||||
public function searchPosts(string $query, array $filters = [], int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
return $this->cacheService
|
||||
->tags(['search', 'posts'])
|
||||
->remember("posts:search:{$query}", function () use ($query, $filters, $perPage) {
|
||||
return Post::with(['author:id,name', 'category:id,name'])
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('title', 'like', "%{$query}%")
|
||||
->orWhere('content', 'like', "%{$query}%")
|
||||
->orWhere('summary', 'like', "%{$query}%");
|
||||
})
|
||||
->when(!empty($filters['category_id']), function ($q) use ($filters) {
|
||||
$q->where('category_id', $filters['category_id']);
|
||||
})
|
||||
->when(!empty($filters['author_id']), function ($q) use ($filters) {
|
||||
$q->where('author_id', $filters['author_id']);
|
||||
})
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
public function getRelatedPosts(Post $post, int $limit = 5): Collection
|
||||
{
|
||||
return $this->cacheService
|
||||
->tags(['posts', 'related'])
|
||||
->remember("posts:{$post->id}:related", function () use ($post, $limit) {
|
||||
return Post::where('category_id', $post->category_id)
|
||||
->where('id', '!=', $post->id)
|
||||
->take($limit)
|
||||
->latest()
|
||||
->get();
|
||||
});
|
||||
}
|
||||
}
|
||||
83
app/Services/StatisticsService.php
Normal file
83
app/Services/StatisticsService.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StatisticsService
|
||||
{
|
||||
protected CacheService $cacheService;
|
||||
|
||||
public function __construct(CacheService $cacheService)
|
||||
{
|
||||
$this->cacheService = $cacheService;
|
||||
}
|
||||
|
||||
public function getPostStatistics(): array
|
||||
{
|
||||
return $this->cacheService->remember('statistics:posts', function () {
|
||||
return [
|
||||
'total_posts' => Post::count(),
|
||||
'posts_by_category' => $this->getPostsByCategory(),
|
||||
'posts_by_author' => $this->getPostsByAuthor(),
|
||||
'most_commented' => $this->getMostCommentedPosts(),
|
||||
'recent_posts' => $this->getRecentPosts(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
protected function getPostsByCategory(): array
|
||||
{
|
||||
return Category::withCount('posts')
|
||||
->orderByDesc('posts_count')
|
||||
->get()
|
||||
->map(fn($category) => [
|
||||
'name' => $category->name,
|
||||
'count' => $category->posts_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getPostsByAuthor(): array
|
||||
{
|
||||
return User::withCount('posts')
|
||||
->orderByDesc('posts_count')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($user) => [
|
||||
'name' => $user->name,
|
||||
'count' => $user->posts_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getMostCommentedPosts(): array
|
||||
{
|
||||
return Post::withCount('comments')
|
||||
->orderByDesc('comments_count')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn($post) => [
|
||||
'title' => $post->title,
|
||||
'comments_count' => $post->comments_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getRecentPosts(): array
|
||||
{
|
||||
return Post::latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn($post) => [
|
||||
'title' => $post->title,
|
||||
'created_at' => $post->created_at,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
17
config/api.php
Normal file
17
config/api.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
return [
|
||||
'throttle' => [
|
||||
'enabled' => env('API_THROTTLE_ENABLED', true),
|
||||
'limit' => env('API_THROTTLE_LIMIT', 60),
|
||||
'decay_minutes' => env('API_THROTTLE_DECAY_MINUTES', 1),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'ttl' => env('API_CACHE_TTL', 3600),
|
||||
'prefix' => env('API_CACHE_PREFIX', 'api_'),
|
||||
],
|
||||
|
||||
'pagination' => [
|
||||
'default_limit' => env('API_PAGINATION_LIMIT', 10),
|
||||
'max_limit' => env('API_PAGINATION_MAX_LIMIT', 100),
|
||||
],
|
||||
];
|
||||
|
|
@ -168,6 +168,7 @@
|
|||
// App\Providers\BroadcastServiceProvider::class,
|
||||
App\Providers\EventServiceProvider::class,
|
||||
App\Providers\RouteServiceProvider::class,
|
||||
App\Providers\ApiServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
'ttl' => env('CACHE_TTL', 3600),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
|
|
@ -106,6 +107,6 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
|
||||
'prefix' => env('CACHE_PREFIX', 'blog_api_'),
|
||||
|
||||
];
|
||||
|
|
|
|||
29
config/l5-swagger.php
Normal file
29
config/l5-swagger.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'default' => 'default',
|
||||
'documentations' => [
|
||||
'default' => [
|
||||
'api' => [
|
||||
'title' => 'Blog API Documentation',
|
||||
],
|
||||
'routes' => [
|
||||
'api' => 'api/documentation',
|
||||
],
|
||||
'paths' => [
|
||||
'use_absolute_path' => env('L5_SWAGGER_USE_ABSOLUTE_PATH', true),
|
||||
'docs_json' => 'api-docs.json',
|
||||
'docs_yaml' => 'api-docs.yaml',
|
||||
'format_to_use_for_docs' => env('L5_FORMAT_TO_USE_FOR_DOCS', 'json'),
|
||||
],
|
||||
],
|
||||
],
|
||||
'security' => [
|
||||
'api_key' => [
|
||||
'type' => 'apiKey',
|
||||
'description' => 'Authorization token',
|
||||
'name' => 'Authorization',
|
||||
'in' => 'header',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -126,6 +126,13 @@
|
|||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/api.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 14,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,83 +1,20 @@
|
|||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort()
|
||||
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
'expiration' => env('SANCTUM_EXPIRATION', null),
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
|
||||
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
|
||||
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
21
database/factories/CommentFactory.php
Normal file
21
database/factories/CommentFactory.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class CommentFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'content' => fake()->paragraph(),
|
||||
'post_id' => Post::factory(),
|
||||
'author_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
database/factories/PostFactory.php
Normal file
23
database/factories/PostFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class PostFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'title' => fake()->sentence(),
|
||||
'summary' => fake()->paragraph(),
|
||||
'content' => fake()->paragraphs(3, true),
|
||||
'author_id' => User::factory(),
|
||||
'category_id' => Category::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -24,11 +25,13 @@ class UserFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'account' => fake()->unique()->userName(),
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'role' => 'author',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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->enum('role', ['admin', 'author'])->default('author')->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('role');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('comments', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('activity_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('action');
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger('model_id');
|
||||
$table->string('description');
|
||||
$table->json('properties')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['model_type', 'model_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('posts', function (Blueprint $table) {
|
||||
$table->unsignedInteger('views')->default(0)->after('category_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropColumn('views');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('account')->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('account');
|
||||
});
|
||||
}
|
||||
};
|
||||
25
database/seeders/CategorySeeder.php
Normal file
25
database/seeders/CategorySeeder.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
['name' => 'Technology', 'description' => 'Tech related posts'],
|
||||
['name' => 'Travel', 'description' => 'Travel experiences'],
|
||||
['name' => 'Food', 'description' => 'Food and cooking'],
|
||||
['name' => 'Lifestyle', 'description' => 'Lifestyle topics'],
|
||||
];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
Category::create($category);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
database/seeders/CommentSeeder.php
Normal file
28
database/seeders/CommentSeeder.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CommentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$posts = Post::all();
|
||||
$users = User::all();
|
||||
|
||||
foreach ($posts as $post) {
|
||||
Comment::factory()
|
||||
->count(rand(2, 5))
|
||||
->create([
|
||||
'post_id' => $post->id,
|
||||
'author_id' => $users->random()->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// \App\Models\User::factory(10)->create();
|
||||
|
||||
// \App\Models\User::factory()->create([
|
||||
// 'name' => 'Test User',
|
||||
// 'email' => 'test@example.com',
|
||||
// ]);
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
CategorySeeder::class,
|
||||
PostSeeder::class,
|
||||
CommentSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
database/seeders/PostSeeder.php
Normal file
28
database/seeders/PostSeeder.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PostSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$authors = User::where('role', 'author')->get();
|
||||
$categories = Category::all();
|
||||
|
||||
foreach ($authors as $author) {
|
||||
Post::factory()
|
||||
->count(3)
|
||||
->create([
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $categories->random()->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
database/seeders/UserSeeder.php
Normal file
28
database/seeders/UserSeeder.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Create admin user
|
||||
User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
// Create some author users
|
||||
User::factory(5)->create([
|
||||
'role' => 'author',
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
database/seeders/sql/admin_user.sql
Normal file
18
database/seeders/sql/admin_user.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- 创建超级管理员账号
|
||||
INSERT INTO `users` (
|
||||
`account`,
|
||||
`name`,
|
||||
`email`,
|
||||
`password`,
|
||||
`role`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
) VALUES (
|
||||
'admin',
|
||||
'Super Admin',
|
||||
'admin@example.com',
|
||||
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- 密码: password
|
||||
'admin',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\CategoryController;
|
||||
use App\Http\Controllers\Api\CommentController;
|
||||
use App\Http\Controllers\Api\PostController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\StatisticsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|
|
@ -17,3 +23,55 @@
|
|||
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
});
|
||||
|
||||
// 公开路由
|
||||
Route::post('/auth/login', [AuthController::class, 'login']);
|
||||
|
||||
// 需要认证的路由
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// 认证相关
|
||||
Route::post('/auth/logout', [AuthController::class, 'logout']);
|
||||
|
||||
// 仅管理员可访问的路由
|
||||
Route::middleware('admin')->group(function () {
|
||||
Route::post('/auth/register', [AuthController::class, 'register']);
|
||||
Route::apiResource('users', UserController::class);
|
||||
});
|
||||
|
||||
// 文章相关
|
||||
Route::apiResource('posts', PostController::class);
|
||||
|
||||
// 评论相关
|
||||
Route::apiResource('posts.comments', CommentController::class)
|
||||
->shallow();
|
||||
|
||||
// 分类相关
|
||||
Route::apiResource('categories', CategoryController::class);
|
||||
|
||||
// 搜索相关路由
|
||||
Route::prefix('search')->group(function () {
|
||||
Route::get('posts', [SearchController::class, 'posts']);
|
||||
Route::get('posts/{post}/related', [SearchController::class, 'relatedPosts']);
|
||||
});
|
||||
|
||||
// 统计相关路由
|
||||
Route::middleware('auth:sanctum')->prefix('statistics')->group(function () {
|
||||
Route::get('posts', [StatisticsController::class, 'posts']);
|
||||
Route::get('user-activities', [StatisticsController::class, 'userActivities']);
|
||||
});
|
||||
|
||||
// 文章阅读量统计
|
||||
Route::get('posts/popular', [PostController::class, 'popular']);
|
||||
|
||||
// 分类相关路由
|
||||
Route::get('categories/{category}/posts', [CategoryController::class, 'posts']);
|
||||
});
|
||||
|
||||
// 添加 API 版本信息路由
|
||||
Route::get('/', function () {
|
||||
return response()->json([
|
||||
'name' => 'Blog API',
|
||||
'version' => '1.0',
|
||||
'documentation' => url('/api/documentation'),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
74
tests/Feature/Auth/LoginTest.php
Normal file
74
tests/Feature/Auth/LoginTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_login_with_email(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'login' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'token',
|
||||
'user' => [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_user_can_login_with_account(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'account' => 'testuser',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'login' => 'testuser',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'token',
|
||||
'user' => [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_user_cannot_login_with_incorrect_credentials(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'login' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
48
tests/Feature/CategoryTest.php
Normal file
48
tests/Feature/CategoryTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CategoryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_category(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->postJson('/api/categories', [
|
||||
'name' => 'New Category',
|
||||
'description' => 'Category Description',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'Category created successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('categories', [
|
||||
'name' => 'New Category',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_author_cannot_create_category(): void
|
||||
{
|
||||
$author = User::factory()->create(['role' => 'author']);
|
||||
|
||||
$response = $this->actingAs($author)
|
||||
->postJson('/api/categories', [
|
||||
'name' => 'New Category',
|
||||
'description' => 'Category Description',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
}
|
||||
58
tests/Feature/CommentTest.php
Normal file
58
tests/Feature/CommentTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CommentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_add_comment(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/posts/{$post->id}/comments", [
|
||||
'content' => 'Test Comment',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'Comment added successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('comments', [
|
||||
'content' => 'Test Comment',
|
||||
'post_id' => $post->id,
|
||||
'author_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_own_comment(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$comment = Comment::factory()->create([
|
||||
'author_id' => $user->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->deleteJson("/api/comments/{$comment->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'Comment deleted successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('comments', [
|
||||
'id' => $comment->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
tests/Feature/PostTest.php
Normal file
59
tests/Feature/PostTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PostTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_view_posts(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$posts = Post::factory(5)->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/posts');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'posts' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'title',
|
||||
'summary',
|
||||
'author',
|
||||
],
|
||||
],
|
||||
'pagination',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_author_can_create_post(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'author']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/posts', [
|
||||
'title' => 'Test Post',
|
||||
'content' => 'Test Content',
|
||||
'summary' => 'Test Summary',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'message' => 'Post created successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('posts', [
|
||||
'title' => 'Test Post',
|
||||
'author_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
tests/Feature/PostViewTest.php
Normal file
59
tests/Feature/PostViewTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PostViewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_post_view_count_increases(): void
|
||||
{
|
||||
$post = Post::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson("/api/posts/{$post->id}");
|
||||
|
||||
$this->assertEquals(1, $post->fresh()->views);
|
||||
}
|
||||
|
||||
public function test_same_ip_does_not_increase_view_count_within_24_hours(): void
|
||||
{
|
||||
$post = Post::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson("/api/posts/{$post->id}");
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson("/api/posts/{$post->id}");
|
||||
|
||||
$this->assertEquals(1, $post->fresh()->views);
|
||||
}
|
||||
|
||||
public function test_can_get_popular_posts(): void
|
||||
{
|
||||
$posts = Post::factory()->count(5)->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
foreach ($posts as $index => $post) {
|
||||
for ($i = 0; $i <= $index; $i++) {
|
||||
$post->increment('views');
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/posts/popular');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(5, 'data')
|
||||
->assertJsonPath('data.0.views', 4);
|
||||
}
|
||||
}
|
||||
19
tests/Traits/WithoutMiddleware.php
Normal file
19
tests/Traits/WithoutMiddleware.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Traits;
|
||||
|
||||
use App\Http\Middleware\ApiRateLimiter;
|
||||
use App\Http\Middleware\LogApiRequests;
|
||||
|
||||
trait WithoutMiddleware
|
||||
{
|
||||
protected function disableMiddleware(): void
|
||||
{
|
||||
$this->withoutMiddleware([
|
||||
ApiRateLimiter::class,
|
||||
LogApiRequests::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue