From b6ff81c7f4c34575bf883a8c254d7e425bffb288 Mon Sep 17 00:00:00 2001
From: Jethro Lin
Date: Sun, 17 Nov 2024 11:30:01 +0800
Subject: [PATCH] claude 3.5 add this ~~
---
.cursorignore | 4 +
.env.example | 3 +
app/Console/Commands/GenerateUserToken.php | 29 ++++
app/Constants/ApiCode.php | 44 ++++++
app/Events/PostCreated.php | 18 +++
app/Events/PostUpdated.php | 18 +++
app/Exceptions/ApiException.php | 37 +++++
app/Exceptions/Handler.php | 56 ++++++-
app/Http/Controllers/Api/AuthController.php | 88 +++++++++++
.../Controllers/Api/CategoryController.php | 84 +++++++++++
.../Controllers/Api/CommentController.php | 66 +++++++++
app/Http/Controllers/Api/PostController.php | 139 ++++++++++++++++++
app/Http/Controllers/Api/SearchController.php | 56 +++++++
.../Controllers/Api/StatisticsController.php | 33 +++++
app/Http/Controllers/Api/UserController.php | 68 +++++++++
app/Http/Controllers/Controller.php | 47 ++++++
app/Http/Kernel.php | 3 +
app/Http/Middleware/AdminMiddleware.php | 23 +++
app/Http/Middleware/ApiRateLimiter.php | 36 +++++
app/Http/Middleware/ApiVersion.php | 22 +++
app/Http/Middleware/FormatApiResponse.php | 48 ++++++
app/Http/Middleware/LogApiRequests.php | 31 ++++
app/Http/Requests/Auth/LoginRequest.php | 23 +++
app/Http/Requests/Auth/RegisterRequest.php | 26 ++++
app/Http/Requests/Post/ListPostRequest.php | 28 ++++
app/Http/Requests/Post/StorePostRequest.php | 25 ++++
app/Http/Requests/Post/UpdatePostRequest.php | 25 ++++
app/Http/Resources/ApiResource.php | 50 +++++++
app/Http/Resources/CategoryResource.php | 23 +++
app/Http/Resources/CommentResource.php | 22 +++
app/Http/Resources/PaginatedResource.php | 24 +++
app/Http/Resources/PostResource.php | 27 ++++
app/Http/Resources/UserResource.php | 23 +++
app/Http/Responses/ApiResponse.php | 53 +++++++
app/Listeners/ClearPostCache.php | 22 +++
app/Models/ActivityLog.php | 35 +++++
app/Models/Category.php | 24 +++
app/Models/Comment.php | 33 +++++
app/Models/Post.php | 41 ++++++
app/Models/User.php | 41 ++++--
app/Policies/CategoryPolicy.php | 26 ++++
app/Policies/CommentPolicy.php | 16 ++
app/Policies/PostPolicy.php | 21 +++
app/Providers/ApiServiceProvider.php | 35 +++++
app/Providers/AuthServiceProvider.php | 21 ++-
app/Providers/EventServiceProvider.php | 24 +--
app/Providers/RouteServiceProvider.php | 2 +-
app/Services/ActivityLogService.php | 45 ++++++
app/Services/CacheService.php | 40 +++++
app/Services/PostService.php | 55 +++++++
app/Services/PostViewService.php | 50 +++++++
app/Services/SearchService.php | 54 +++++++
app/Services/StatisticsService.php | 83 +++++++++++
config/api.php | 17 +++
config/app.php | 1 +
config/cache.php | 3 +-
config/l5-swagger.php | 29 ++++
config/logging.php | 7 +
config/sanctum.php | 71 +--------
database/factories/CommentFactory.php | 21 +++
database/factories/PostFactory.php | 23 +++
database/factories/UserFactory.php | 3 +
...4_01_01_000000_add_role_to_users_table.php | 24 +++
...000_add_soft_deletes_to_comments_table.php | 24 +++
...000000_add_soft_deletes_to_posts_table.php | 26 ++++
...1_02_000000_create_activity_logs_table.php | 31 ++++
..._01_03_000000_add_views_to_posts_table.php | 24 +++
...1_04_000000_add_account_to_users_table.php | 24 +++
database/seeders/CategorySeeder.php | 25 ++++
database/seeders/CommentSeeder.php | 28 ++++
database/seeders/DatabaseSeeder.php | 18 +--
database/seeders/PostSeeder.php | 28 ++++
database/seeders/UserSeeder.php | 28 ++++
database/seeders/sql/admin_user.sql | 18 +++
routes/api.php | 60 +++++++-
tests/Feature/Auth/LoginTest.php | 74 ++++++++++
tests/Feature/CategoryTest.php | 48 ++++++
tests/Feature/CommentTest.php | 58 ++++++++
tests/Feature/PostTest.php | 59 ++++++++
tests/Feature/PostViewTest.php | 59 ++++++++
tests/Traits/WithoutMiddleware.php | 19 +++
81 files changed, 2695 insertions(+), 125 deletions(-)
create mode 100644 .cursorignore
create mode 100644 app/Console/Commands/GenerateUserToken.php
create mode 100644 app/Constants/ApiCode.php
create mode 100644 app/Events/PostCreated.php
create mode 100644 app/Events/PostUpdated.php
create mode 100644 app/Exceptions/ApiException.php
create mode 100644 app/Http/Controllers/Api/AuthController.php
create mode 100644 app/Http/Controllers/Api/CategoryController.php
create mode 100644 app/Http/Controllers/Api/CommentController.php
create mode 100644 app/Http/Controllers/Api/PostController.php
create mode 100644 app/Http/Controllers/Api/SearchController.php
create mode 100644 app/Http/Controllers/Api/StatisticsController.php
create mode 100644 app/Http/Controllers/Api/UserController.php
create mode 100644 app/Http/Middleware/AdminMiddleware.php
create mode 100644 app/Http/Middleware/ApiRateLimiter.php
create mode 100644 app/Http/Middleware/ApiVersion.php
create mode 100644 app/Http/Middleware/FormatApiResponse.php
create mode 100644 app/Http/Middleware/LogApiRequests.php
create mode 100644 app/Http/Requests/Auth/LoginRequest.php
create mode 100644 app/Http/Requests/Auth/RegisterRequest.php
create mode 100644 app/Http/Requests/Post/ListPostRequest.php
create mode 100644 app/Http/Requests/Post/StorePostRequest.php
create mode 100644 app/Http/Requests/Post/UpdatePostRequest.php
create mode 100644 app/Http/Resources/ApiResource.php
create mode 100644 app/Http/Resources/CategoryResource.php
create mode 100644 app/Http/Resources/CommentResource.php
create mode 100644 app/Http/Resources/PaginatedResource.php
create mode 100644 app/Http/Resources/PostResource.php
create mode 100644 app/Http/Resources/UserResource.php
create mode 100644 app/Http/Responses/ApiResponse.php
create mode 100644 app/Listeners/ClearPostCache.php
create mode 100644 app/Models/ActivityLog.php
create mode 100644 app/Models/Category.php
create mode 100644 app/Models/Comment.php
create mode 100644 app/Models/Post.php
create mode 100644 app/Policies/CategoryPolicy.php
create mode 100644 app/Policies/CommentPolicy.php
create mode 100644 app/Policies/PostPolicy.php
create mode 100644 app/Providers/ApiServiceProvider.php
create mode 100644 app/Services/ActivityLogService.php
create mode 100644 app/Services/CacheService.php
create mode 100644 app/Services/PostService.php
create mode 100644 app/Services/PostViewService.php
create mode 100644 app/Services/SearchService.php
create mode 100644 app/Services/StatisticsService.php
create mode 100644 config/api.php
create mode 100644 config/l5-swagger.php
create mode 100644 database/factories/CommentFactory.php
create mode 100644 database/factories/PostFactory.php
create mode 100644 database/migrations/2024_01_01_000000_add_role_to_users_table.php
create mode 100644 database/migrations/2024_01_02_000000_add_soft_deletes_to_comments_table.php
create mode 100644 database/migrations/2024_01_02_000000_add_soft_deletes_to_posts_table.php
create mode 100644 database/migrations/2024_01_02_000000_create_activity_logs_table.php
create mode 100644 database/migrations/2024_01_03_000000_add_views_to_posts_table.php
create mode 100644 database/migrations/2024_01_04_000000_add_account_to_users_table.php
create mode 100644 database/seeders/CategorySeeder.php
create mode 100644 database/seeders/CommentSeeder.php
create mode 100644 database/seeders/PostSeeder.php
create mode 100644 database/seeders/UserSeeder.php
create mode 100644 database/seeders/sql/admin_user.sql
create mode 100644 tests/Feature/Auth/LoginTest.php
create mode 100644 tests/Feature/CategoryTest.php
create mode 100644 tests/Feature/CommentTest.php
create mode 100644 tests/Feature/PostTest.php
create mode 100644 tests/Feature/PostViewTest.php
create mode 100644 tests/Traits/WithoutMiddleware.php
diff --git a/.cursorignore b/.cursorignore
new file mode 100644
index 0000000..354165e
--- /dev/null
+++ b/.cursorignore
@@ -0,0 +1,4 @@
+# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
+/.idea
+/.vscode
+/.fleet
diff --git a/.env.example b/.env.example
index ea0665b..df2378b 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/app/Console/Commands/GenerateUserToken.php b/app/Console/Commands/GenerateUserToken.php
new file mode 100644
index 0000000..dc4f6d5
--- /dev/null
+++ b/app/Console/Commands/GenerateUserToken.php
@@ -0,0 +1,29 @@
+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}");
+ }
+}
\ No newline at end of file
diff --git a/app/Constants/ApiCode.php b/app/Constants/ApiCode.php
new file mode 100644
index 0000000..b4f4f82
--- /dev/null
+++ b/app/Constants/ApiCode.php
@@ -0,0 +1,44 @@
+ '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',
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Events/PostCreated.php b/app/Events/PostCreated.php
new file mode 100644
index 0000000..f0fd710
--- /dev/null
+++ b/app/Events/PostCreated.php
@@ -0,0 +1,18 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 56af264..623d3a0 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -1,9 +1,17 @@
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);
});
}
}
diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php
new file mode 100644
index 0000000..e2e8c5a
--- /dev/null
+++ b/app/Http/Controllers/Api/AuthController.php
@@ -0,0 +1,88 @@
+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',
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/CategoryController.php b/app/Http/Controllers/Api/CategoryController.php
new file mode 100644
index 0000000..32bbc6b
--- /dev/null
+++ b/app/Http/Controllers/Api/CategoryController.php
@@ -0,0 +1,84 @@
+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(),
+ ]
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/CommentController.php b/app/Http/Controllers/Api/CommentController.php
new file mode 100644
index 0000000..701228c
--- /dev/null
+++ b/app/Http/Controllers/Api/CommentController.php
@@ -0,0 +1,66 @@
+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'
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php
new file mode 100644
index 0000000..eef402d
--- /dev/null
+++ b/app/Http/Controllers/Api/PostController.php
@@ -0,0 +1,139 @@
+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(),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/SearchController.php b/app/Http/Controllers/Api/SearchController.php
new file mode 100644
index 0000000..fc8b6df
--- /dev/null
+++ b/app/Http/Controllers/Api/SearchController.php
@@ -0,0 +1,56 @@
+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),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/StatisticsController.php b/app/Http/Controllers/Api/StatisticsController.php
new file mode 100644
index 0000000..11eb28b
--- /dev/null
+++ b/app/Http/Controllers/Api/StatisticsController.php
@@ -0,0 +1,33 @@
+json([
+ 'data' => $this->statisticsService->getPostStatistics(),
+ ]);
+ }
+
+ public function userActivities(Request $request): JsonResponse
+ {
+ return response()->json([
+ 'data' => $this->activityLogService->getUserActivities($request->user()),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php
new file mode 100644
index 0000000..a705f93
--- /dev/null
+++ b/app/Http/Controllers/Api/UserController.php
@@ -0,0 +1,68 @@
+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'
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 77ec359..001ab84 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -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;
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 494c050..4720935 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -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,
diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php
new file mode 100644
index 0000000..0a74efc
--- /dev/null
+++ b/app/Http/Middleware/AdminMiddleware.php
@@ -0,0 +1,23 @@
+user() || ! $request->user()->isAdmin()) {
+ return response()->json([
+ 'message' => 'Unauthorized. Admin access required.',
+ ], 403);
+ }
+
+ return $next($request);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/ApiRateLimiter.php b/app/Http/Middleware/ApiRateLimiter.php
new file mode 100644
index 0000000..e74d3e1
--- /dev/null
+++ b/app/Http/Middleware/ApiRateLimiter.php
@@ -0,0 +1,36 @@
+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));
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/ApiVersion.php b/app/Http/Middleware/ApiVersion.php
new file mode 100644
index 0000000..4722ef8
--- /dev/null
+++ b/app/Http/Middleware/ApiVersion.php
@@ -0,0 +1,22 @@
+headers->set('X-API-Version', 'v1');
+
+ return $response;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/FormatApiResponse.php b/app/Http/Middleware/FormatApiResponse.php
new file mode 100644
index 0000000..42d9341
--- /dev/null
+++ b/app/Http/Middleware/FormatApiResponse.php
@@ -0,0 +1,48 @@
+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');
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/LogApiRequests.php b/app/Http/Middleware/LogApiRequests.php
new file mode 100644
index 0000000..2a0bab1
--- /dev/null
+++ b/app/Http/Middleware/LogApiRequests.php
@@ -0,0 +1,31 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php
new file mode 100644
index 0000000..4f39d32
--- /dev/null
+++ b/app/Http/Requests/Auth/LoginRequest.php
@@ -0,0 +1,23 @@
+ 'required|string',
+ 'password' => 'required|string',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php
new file mode 100644
index 0000000..f6ada01
--- /dev/null
+++ b/app/Http/Requests/Auth/RegisterRequest.php
@@ -0,0 +1,26 @@
+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',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/Post/ListPostRequest.php b/app/Http/Requests/Post/ListPostRequest.php
new file mode 100644
index 0000000..be787d7
--- /dev/null
+++ b/app/Http/Requests/Post/ListPostRequest.php
@@ -0,0 +1,28 @@
+ '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',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/Post/StorePostRequest.php b/app/Http/Requests/Post/StorePostRequest.php
new file mode 100644
index 0000000..6e4c5b9
--- /dev/null
+++ b/app/Http/Requests/Post/StorePostRequest.php
@@ -0,0 +1,25 @@
+ 'required|string|max:255',
+ 'content' => 'required|string',
+ 'summary' => 'nullable|string|max:500',
+ 'category_id' => 'nullable|exists:categories,id',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/Post/UpdatePostRequest.php b/app/Http/Requests/Post/UpdatePostRequest.php
new file mode 100644
index 0000000..364c73b
--- /dev/null
+++ b/app/Http/Requests/Post/UpdatePostRequest.php
@@ -0,0 +1,25 @@
+ 'sometimes|string|max:255',
+ 'content' => 'sometimes|string',
+ 'category_id' => 'nullable|exists:categories,id',
+ 'summary' => 'nullable|string|max:500',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/ApiResource.php b/app/Http/Resources/ApiResource.php
new file mode 100644
index 0000000..c01883e
--- /dev/null
+++ b/app/Http/Resources/ApiResource.php
@@ -0,0 +1,50 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php
new file mode 100644
index 0000000..f4f497d
--- /dev/null
+++ b/app/Http/Resources/CategoryResource.php
@@ -0,0 +1,23 @@
+ $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,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/CommentResource.php b/app/Http/Resources/CommentResource.php
new file mode 100644
index 0000000..9c87932
--- /dev/null
+++ b/app/Http/Resources/CommentResource.php
@@ -0,0 +1,22 @@
+ $this->id,
+ 'content' => $this->content,
+ 'author' => new UserResource($this->whenLoaded('author')),
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/PaginatedResource.php b/app/Http/Resources/PaginatedResource.php
new file mode 100644
index 0000000..e59b63f
--- /dev/null
+++ b/app/Http/Resources/PaginatedResource.php
@@ -0,0 +1,24 @@
+ $this->collection,
+ 'pagination' => [
+ 'total' => $this->resource->total(),
+ 'per_page' => $this->resource->perPage(),
+ 'current_page' => $this->resource->currentPage(),
+ 'last_page' => $this->resource->lastPage(),
+ ],
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php
new file mode 100644
index 0000000..740f5ea
--- /dev/null
+++ b/app/Http/Resources/PostResource.php
@@ -0,0 +1,27 @@
+ $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,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
new file mode 100644
index 0000000..c52d8a1
--- /dev/null
+++ b/app/Http/Resources/UserResource.php
@@ -0,0 +1,23 @@
+ $this->id,
+ 'name' => $this->name,
+ 'email' => $this->email,
+ 'role' => $this->role,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Responses/ApiResponse.php b/app/Http/Responses/ApiResponse.php
new file mode 100644
index 0000000..7edc63d
--- /dev/null
+++ b/app/Http/Responses/ApiResponse.php
@@ -0,0 +1,53 @@
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/app/Listeners/ClearPostCache.php b/app/Listeners/ClearPostCache.php
new file mode 100644
index 0000000..6f1ddf8
--- /dev/null
+++ b/app/Listeners/ClearPostCache.php
@@ -0,0 +1,22 @@
+flush();
+ }
+}
\ No newline at end of file
diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php
new file mode 100644
index 0000000..5346aaa
--- /dev/null
+++ b/app/Models/ActivityLog.php
@@ -0,0 +1,35 @@
+ 'array',
+ ];
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo('model');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..0cbb0db
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,24 @@
+hasMany(Post::class);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Comment.php b/app/Models/Comment.php
new file mode 100644
index 0000000..031b863
--- /dev/null
+++ b/app/Models/Comment.php
@@ -0,0 +1,33 @@
+belongsTo(Post::class);
+ }
+
+ public function author(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'author_id');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Post.php b/app/Models/Post.php
new file mode 100644
index 0000000..795b0a2
--- /dev/null
+++ b/app/Models/Post.php
@@ -0,0 +1,41 @@
+belongsTo(User::class, 'author_id');
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(Category::class);
+ }
+
+ public function comments(): HasMany
+ {
+ return $this->hasMany(Comment::class);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/User.php b/app/Models/User.php
index 4d7f70f..6e09bbf 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -1,45 +1,54 @@
- */
protected $fillable = [
+ 'account',
'name',
'email',
'password',
+ 'role',
];
- /**
- * The attributes that should be hidden for serialization.
- *
- * @var array
- */
protected $hidden = [
'password',
'remember_token',
];
- /**
- * The attributes that should be cast.
- *
- * @var array
- */
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';
+ }
}
diff --git a/app/Policies/CategoryPolicy.php b/app/Policies/CategoryPolicy.php
new file mode 100644
index 0000000..0a115c9
--- /dev/null
+++ b/app/Policies/CategoryPolicy.php
@@ -0,0 +1,26 @@
+isAdmin();
+ }
+
+ public function update(User $user): bool
+ {
+ return $user->isAdmin();
+ }
+
+ public function delete(User $user): bool
+ {
+ return $user->isAdmin();
+ }
+}
\ No newline at end of file
diff --git a/app/Policies/CommentPolicy.php b/app/Policies/CommentPolicy.php
new file mode 100644
index 0000000..ddd6bed
--- /dev/null
+++ b/app/Policies/CommentPolicy.php
@@ -0,0 +1,16 @@
+isAdmin() || $comment->author_id === $user->id;
+ }
+}
\ No newline at end of file
diff --git a/app/Policies/PostPolicy.php b/app/Policies/PostPolicy.php
new file mode 100644
index 0000000..f457965
--- /dev/null
+++ b/app/Policies/PostPolicy.php
@@ -0,0 +1,21 @@
+isAdmin() || $post->author_id === $user->id;
+ }
+
+ public function delete(User $user, Post $post): bool
+ {
+ return $user->isAdmin() || $post->author_id === $user->id;
+ }
+}
\ No newline at end of file
diff --git a/app/Providers/ApiServiceProvider.php b/app/Providers/ApiServiceProvider.php
new file mode 100644
index 0000000..a222fec
--- /dev/null
+++ b/app/Providers/ApiServiceProvider.php
@@ -0,0 +1,35 @@
+ 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);
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index 54756cd..e146bae 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -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
- */
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();
}
}
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
index 2d65aac..4770d04 100644
--- a/app/Providers/EventServiceProvider.php
+++ b/app/Providers/EventServiceProvider.php
@@ -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>
- */
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;
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 1cf5f15..a457689 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -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')
diff --git a/app/Services/ActivityLogService.php b/app/Services/ActivityLogService.php
new file mode 100644
index 0000000..b774682
--- /dev/null
+++ b/app/Services/ActivityLogService.php
@@ -0,0 +1,45 @@
+ $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();
+ }
+}
\ No newline at end of file
diff --git a/app/Services/CacheService.php b/app/Services/CacheService.php
new file mode 100644
index 0000000..0e2e784
--- /dev/null
+++ b/app/Services/CacheService.php
@@ -0,0 +1,40 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/PostService.php b/app/Services/PostService.php
new file mode 100644
index 0000000..f18c17d
--- /dev/null
+++ b/app/Services/PostService.php
@@ -0,0 +1,55 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/PostViewService.php b/app/Services/PostViewService.php
new file mode 100644
index 0000000..d2227a2
--- /dev/null
+++ b/app/Services/PostViewService.php
@@ -0,0 +1,50 @@
+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();
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php
new file mode 100644
index 0000000..19164fd
--- /dev/null
+++ b/app/Services/SearchService.php
@@ -0,0 +1,54 @@
+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();
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php
new file mode 100644
index 0000000..1631eaa
--- /dev/null
+++ b/app/Services/StatisticsService.php
@@ -0,0 +1,83 @@
+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();
+ }
+}
\ No newline at end of file
diff --git a/config/api.php b/config/api.php
new file mode 100644
index 0000000..36f19de
--- /dev/null
+++ b/config/api.php
@@ -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),
+ ],
+];
\ No newline at end of file
diff --git a/config/app.php b/config/app.php
index 9207160..13a86e1 100644
--- a/config/app.php
+++ b/config/app.php
@@ -168,6 +168,7 @@
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
+ App\Providers\ApiServiceProvider::class,
])->toArray(),
/*
diff --git a/config/cache.php b/config/cache.php
index d4171e2..5ee1a16 100644
--- a/config/cache.php
+++ b/config/cache.php
@@ -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_'),
];
diff --git a/config/l5-swagger.php b/config/l5-swagger.php
new file mode 100644
index 0000000..3142821
--- /dev/null
+++ b/config/l5-swagger.php
@@ -0,0 +1,29 @@
+ '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',
+ ],
+ ],
+];
\ No newline at end of file
diff --git a/config/logging.php b/config/logging.php
index c44d276..e0d5f6e 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -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,
+ ],
],
];
diff --git a/config/sanctum.php b/config/sanctum.php
index 35d75b3..b3cd628 100644
--- a/config/sanctum.php
+++ b/config/sanctum.php
@@ -1,83 +1,20 @@
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,
],
-
];
diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php
new file mode 100644
index 0000000..a7844e2
--- /dev/null
+++ b/database/factories/CommentFactory.php
@@ -0,0 +1,21 @@
+ fake()->paragraph(),
+ 'post_id' => Post::factory(),
+ 'author_id' => User::factory(),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php
new file mode 100644
index 0000000..f851cb1
--- /dev/null
+++ b/database/factories/PostFactory.php
@@ -0,0 +1,23 @@
+ fake()->sentence(),
+ 'summary' => fake()->paragraph(),
+ 'content' => fake()->paragraphs(3, true),
+ 'author_id' => User::factory(),
+ 'category_id' => Category::factory(),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index 584104c..930dae3 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -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',
];
}
diff --git a/database/migrations/2024_01_01_000000_add_role_to_users_table.php b/database/migrations/2024_01_01_000000_add_role_to_users_table.php
new file mode 100644
index 0000000..9a9c213
--- /dev/null
+++ b/database/migrations/2024_01_01_000000_add_role_to_users_table.php
@@ -0,0 +1,24 @@
+enum('role', ['admin', 'author'])->default('author')->after('password');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('role');
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2024_01_02_000000_add_soft_deletes_to_comments_table.php b/database/migrations/2024_01_02_000000_add_soft_deletes_to_comments_table.php
new file mode 100644
index 0000000..b36b76a
--- /dev/null
+++ b/database/migrations/2024_01_02_000000_add_soft_deletes_to_comments_table.php
@@ -0,0 +1,24 @@
+softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('comments', function (Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2024_01_02_000000_add_soft_deletes_to_posts_table.php b/database/migrations/2024_01_02_000000_add_soft_deletes_to_posts_table.php
new file mode 100644
index 0000000..f36d661
--- /dev/null
+++ b/database/migrations/2024_01_02_000000_add_soft_deletes_to_posts_table.php
@@ -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();
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2024_01_02_000000_create_activity_logs_table.php b/database/migrations/2024_01_02_000000_create_activity_logs_table.php
new file mode 100644
index 0000000..0297a2b
--- /dev/null
+++ b/database/migrations/2024_01_02_000000_create_activity_logs_table.php
@@ -0,0 +1,31 @@
+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');
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2024_01_03_000000_add_views_to_posts_table.php b/database/migrations/2024_01_03_000000_add_views_to_posts_table.php
new file mode 100644
index 0000000..4a9b4d9
--- /dev/null
+++ b/database/migrations/2024_01_03_000000_add_views_to_posts_table.php
@@ -0,0 +1,24 @@
+unsignedInteger('views')->default(0)->after('category_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('views');
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2024_01_04_000000_add_account_to_users_table.php b/database/migrations/2024_01_04_000000_add_account_to_users_table.php
new file mode 100644
index 0000000..dfc3e75
--- /dev/null
+++ b/database/migrations/2024_01_04_000000_add_account_to_users_table.php
@@ -0,0 +1,24 @@
+string('account')->unique()->after('id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('account');
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php
new file mode 100644
index 0000000..5da8948
--- /dev/null
+++ b/database/seeders/CategorySeeder.php
@@ -0,0 +1,25 @@
+ '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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/database/seeders/CommentSeeder.php b/database/seeders/CommentSeeder.php
new file mode 100644
index 0000000..213a47d
--- /dev/null
+++ b/database/seeders/CommentSeeder.php
@@ -0,0 +1,28 @@
+count(rand(2, 5))
+ ->create([
+ 'post_id' => $post->id,
+ 'author_id' => $users->random()->id,
+ ]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index a9f4519..91df74f 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -1,22 +1,20 @@
create();
-
- // \App\Models\User::factory()->create([
- // 'name' => 'Test User',
- // 'email' => 'test@example.com',
- // ]);
+ $this->call([
+ UserSeeder::class,
+ CategorySeeder::class,
+ PostSeeder::class,
+ CommentSeeder::class,
+ ]);
}
}
diff --git a/database/seeders/PostSeeder.php b/database/seeders/PostSeeder.php
new file mode 100644
index 0000000..e6b93cd
--- /dev/null
+++ b/database/seeders/PostSeeder.php
@@ -0,0 +1,28 @@
+get();
+ $categories = Category::all();
+
+ foreach ($authors as $author) {
+ Post::factory()
+ ->count(3)
+ ->create([
+ 'author_id' => $author->id,
+ 'category_id' => $categories->random()->id,
+ ]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php
new file mode 100644
index 0000000..30713ed
--- /dev/null
+++ b/database/seeders/UserSeeder.php
@@ -0,0 +1,28 @@
+ 'Admin User',
+ 'email' => 'admin@example.com',
+ 'password' => Hash::make('password'),
+ 'role' => 'admin',
+ ]);
+
+ // Create some author users
+ User::factory(5)->create([
+ 'role' => 'author',
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/database/seeders/sql/admin_user.sql b/database/seeders/sql/admin_user.sql
new file mode 100644
index 0000000..bb2ef58
--- /dev/null
+++ b/database/seeders/sql/admin_user.sql
@@ -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()
+);
\ No newline at end of file
diff --git a/routes/api.php b/routes/api.php
index 889937e..2b8e88f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,6 +1,12 @@
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'),
+ ]);
+});
diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php
new file mode 100644
index 0000000..920e71f
--- /dev/null
+++ b/tests/Feature/Auth/LoginTest.php
@@ -0,0 +1,74 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/tests/Feature/CategoryTest.php b/tests/Feature/CategoryTest.php
new file mode 100644
index 0000000..74577ca
--- /dev/null
+++ b/tests/Feature/CategoryTest.php
@@ -0,0 +1,48 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/tests/Feature/CommentTest.php b/tests/Feature/CommentTest.php
new file mode 100644
index 0000000..92d1bea
--- /dev/null
+++ b/tests/Feature/CommentTest.php
@@ -0,0 +1,58 @@
+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,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/tests/Feature/PostTest.php b/tests/Feature/PostTest.php
new file mode 100644
index 0000000..b252bd2
--- /dev/null
+++ b/tests/Feature/PostTest.php
@@ -0,0 +1,59 @@
+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,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/tests/Feature/PostViewTest.php b/tests/Feature/PostViewTest.php
new file mode 100644
index 0000000..d2dd6d8
--- /dev/null
+++ b/tests/Feature/PostViewTest.php
@@ -0,0 +1,59 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/tests/Traits/WithoutMiddleware.php b/tests/Traits/WithoutMiddleware.php
new file mode 100644
index 0000000..3b873d2
--- /dev/null
+++ b/tests/Traits/WithoutMiddleware.php
@@ -0,0 +1,19 @@
+withoutMiddleware([
+ ApiRateLimiter::class,
+ LogApiRequests::class,
+ ]);
+ }
+}
\ No newline at end of file