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