claude 3.5 add this ~~

This commit is contained in:
Jethro Lin 2024-11-17 11:30:01 +08:00
parent 9bf337a493
commit b6ff81c7f4
81 changed files with 2695 additions and 125 deletions

4
.cursorignore Normal file
View file

@ -0,0 +1,4 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
/.idea
/.vscode
/.fleet

View file

@ -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

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class GenerateUserToken extends Command
{
protected $signature = 'user:token {email}';
protected $description = 'Generate API token for a user';
public function handle(): void
{
$email = $this->argument('email');
$user = User::where('email', $email)->first();
if (!$user) {
$this->error("User not found with email: {$email}");
return;
}
$token = $user->createToken('cli-token')->plainTextToken;
$this->info("Token generated successfully for user: {$user->name}");
$this->info("Token: {$token}");
}
}

44
app/Constants/ApiCode.php Normal file
View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Constants;
class ApiCode
{
public const SUCCESS = 200;
public const CREATED = 201;
public const ACCEPTED = 202;
public const NO_CONTENT = 204;
public const BAD_REQUEST = 400;
public const UNAUTHORIZED = 401;
public const FORBIDDEN = 403;
public const NOT_FOUND = 404;
public const METHOD_NOT_ALLOWED = 405;
public const UNPROCESSABLE_ENTITY = 422;
public const TOO_MANY_REQUESTS = 429;
public const SERVER_ERROR = 500;
public const SERVICE_UNAVAILABLE = 503;
public static function getMessage(int $code): string
{
return match ($code) {
self::SUCCESS => 'Success',
self::CREATED => 'Created',
self::ACCEPTED => 'Accepted',
self::NO_CONTENT => 'No Content',
self::BAD_REQUEST => 'Bad Request',
self::UNAUTHORIZED => 'Unauthorized',
self::FORBIDDEN => 'Forbidden',
self::NOT_FOUND => 'Not Found',
self::METHOD_NOT_ALLOWED => 'Method Not Allowed',
self::UNPROCESSABLE_ENTITY => 'Unprocessable Entity',
self::TOO_MANY_REQUESTS => 'Too Many Requests',
self::SERVER_ERROR => 'Server Error',
self::SERVICE_UNAVAILABLE => 'Service Unavailable',
default => 'Unknown Error',
};
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Post;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public Post $post
) {}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Post;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostUpdated
{
use Dispatchable, SerializesModels;
public function __construct(
public Post $post
) {}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class ApiException extends Exception
{
protected $statusCode = 400;
protected $errors = [];
public function __construct(string $message = '', int $code = 0, array $errors = [])
{
parent::__construct($message, $code);
$this->errors = $errors;
if ($code) {
$this->statusCode = $code;
}
}
public function render(): JsonResponse
{
$response = [
'error' => true,
'message' => $this->message,
];
if (!empty($this->errors)) {
$response['errors'] = $this->errors;
}
return response()->json($response, $this->statusCode);
}
}

View file

@ -1,9 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Illuminate\Auth\Access\AuthorizationException;
use Throwable;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
class Handler extends ExceptionHandler
{
@ -23,8 +31,52 @@ class Handler extends ExceptionHandler
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
$this->renderable(function (AuthenticationException $e) {
return response()->json([
'error' => 'Unauthenticated',
'message' => 'You must be logged in to access this resource.',
], 401);
});
$this->renderable(function (ValidationException $e) {
return response()->json([
'error' => 'Validation Error',
'message' => 'The given data was invalid.',
'errors' => $e->errors(),
], 422);
});
$this->renderable(function (NotFoundHttpException $e) {
return response()->json([
'error' => 'Not Found',
'message' => 'The requested resource was not found.',
], 404);
});
$this->renderable(function (AuthorizationException $e) {
return response()->json([
'error' => 'Forbidden',
'message' => 'You are not authorized to perform this action.',
], 403);
});
$this->renderable(function (Throwable $e) {
if (config('app.debug')) {
throw $e;
}
return response()->json([
'error' => 'Server Error',
'message' => 'An unexpected error occurred.',
], 500);
});
$this->renderable(function (ModelNotFoundException $e) {
return response()->error('Resource not found', 404);
});
$this->renderable(function (QueryException $e) {
return response()->error('Database error', 500);
});
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(RegisterRequest $request): JsonResponse
{
$validated = $request->validated();
$validated['password'] = Hash::make($validated['password']);
$user = User::create($validated);
return response()->json([
'message' => 'User registered successfully',
'user' => new UserResource($user),
]);
}
/**
* @OA\Post(
* path="/api/auth/login",
* summary="User login",
* tags={"Authentication"},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"login","password"},
* @OA\Property(property="login", type="string", description="Email or account"),
* @OA\Property(property="password", type="string", format="password")
* )
* ),
* @OA\Response(
* response=200,
* description="Login successful",
* @OA\JsonContent(
* @OA\Property(property="token", type="string"),
* @OA\Property(property="user", type="object")
* )
* ),
* @OA\Response(
* response=422,
* description="Validation error"
* )
* )
*/
public function login(LoginRequest $request): JsonResponse
{
$login = $request->input('login');
$password = $request->input('password');
// 判断登录字段是邮箱还是账号
$field = filter_var($login, FILTER_VALIDATE_EMAIL) ? 'email' : 'account';
$user = User::where($field, $login)->first();
if (! $user || ! Hash::check($password, $user->password)) {
throw ValidationException::withMessages([
'login' => ['The provided credentials are incorrect.'],
]);
}
return response()->json([
'token' => $user->createToken('auth_token')->plainTextToken,
'user' => new UserResource($user),
]);
}
public function logout(): JsonResponse
{
auth()->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Successfully logged out',
]);
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Resources\CategoryResource;
use App\Http\Resources\PostResource;
class CategoryController extends Controller
{
public function index(): JsonResponse
{
$categories = Category::withCount('posts')->get();
return response()->json([
'categories' => CategoryResource::collection($categories)
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:categories',
'description' => 'nullable|string',
]);
$category = Category::create($validated);
return response()->json([
'message' => 'Category created successfully',
'category' => $category
]);
}
public function update(Request $request, Category $category): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:255|unique:categories,name,' . $category->id,
'description' => 'nullable|string',
]);
$category->update($validated);
return response()->json([
'message' => 'Category updated successfully',
'category' => $category
]);
}
public function destroy(Category $category): JsonResponse
{
$category->delete();
return response()->json([
'message' => 'Category deleted successfully'
]);
}
public function posts(Category $category, Request $request): JsonResponse
{
$perPage = $request->input('per_page', 10);
$perPage = min($perPage, 100);
$posts = $category->posts()
->with(['author:id,name'])
->latest()
->paginate($perPage);
return response()->json([
'posts' => PostResource::collection($posts),
'pagination' => [
'total' => $posts->total(),
'per_page' => $posts->perPage(),
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
]
]);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\Comment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function index(Post $post): JsonResponse
{
$comments = $post->comments()
->with('author:id,name')
->get()
->map(fn($comment) => [
'id' => $comment->id,
'content' => $comment->content,
'author' => $comment->author,
'created_at' => $comment->created_at,
]);
return response()->json([
'comments' => $comments
]);
}
public function store(Request $request, Post $post): JsonResponse
{
$validated = $request->validate([
'content' => 'required|string',
]);
$comment = $post->comments()->create([
...$validated,
'author_id' => $request->user()->id,
]);
$comment->load('author:id,name');
return response()->json([
'message' => 'Comment added successfully',
'comment' => [
'id' => $comment->id,
'content' => $comment->content,
'author' => $comment->author,
'created_at' => $comment->created_at,
]
]);
}
public function destroy(Comment $comment): JsonResponse
{
$this->authorize('delete', $comment);
$comment->delete();
return response()->json([
'message' => 'Comment deleted successfully'
]);
}
}

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Requests\Post\ListPostRequest;
use App\Http\Resources\PaginatedResource;
class PostController extends Controller
{
public function index(ListPostRequest $request): JsonResponse
{
$validated = $request->validated();
$perPage = min($request->input('per_page', 10), 100);
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
$allowedSortFields = ['created_at', 'title', 'views'];
$sortBy = in_array($sortBy, $allowedSortFields) ? $sortBy : 'created_at';
$sortOrder = in_array($sortOrder, ['asc', 'desc']) ? $sortOrder : 'desc';
$query = Post::with(['author:id,name', 'category:id,name'])
->when($request->authorId, fn($q) => $q->where('author_id', $request->authorId))
->when($request->category, fn($q) => $q->whereHas('category', fn($q) =>
$q->where('name', $request->category)
))
->when($request->search, fn($q) => $q->where(fn($q) =>
$q->where('title', 'like', "%{$request->search}%")
->orWhere('content', 'like', "%{$request->search}%")
));
$query->orderBy($sortBy, $sortOrder);
$posts = $query->paginate($perPage);
return response()->success([
'posts' => new PaginatedResource($posts)
]);
}
public function show(Request $request, Post $post): JsonResponse
{
$this->postViewService->recordView($post, $request->ip());
$post->load(['author:id,name', 'comments.author:id,name']);
return response()->json([
'post' => [
'id' => $post->id,
'title' => $post->title,
'content' => $post->content,
'author' => $post->author,
'comments' => $post->comments->map(fn($comment) => [
'id' => $comment->id,
'content' => $comment->content,
'author' => $comment->author,
'created_at' => $comment->created_at,
]),
'created_at' => $post->created_at,
]
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'category_id' => 'nullable|exists:categories,id',
'summary' => 'nullable|string|max:500',
]);
$post = $request->user()->posts()->create($validated);
return response()->json([
'message' => 'Post created successfully',
'post' => [
'id' => $post->id,
'title' => $post->title,
'content' => $post->content,
'author' => [
'id' => $request->user()->id,
'name' => $request->user()->name,
],
'created_at' => $post->created_at,
]
]);
}
public function update(Request $request, Post $post): JsonResponse
{
$this->authorize('update', $post);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'content' => 'sometimes|string',
'category_id' => 'nullable|exists:categories,id',
'summary' => 'nullable|string|max:500',
]);
$post->update($validated);
return response()->json([
'message' => 'Post updated successfully',
'post' => [
'id' => $post->id,
'title' => $post->title,
'content' => $post->content,
'author' => [
'id' => $post->author->id,
'name' => $post->author->name,
],
'updated_at' => $post->updated_at,
]
]);
}
public function destroy(Post $post): JsonResponse
{
$this->authorize('delete', $post);
$post->delete();
return response()->json([
'message' => 'Post deleted successfully'
]);
}
public function popular(Request $request): JsonResponse
{
return response()->json([
'data' => $this->postViewService->getPopularPosts(),
]);
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Services\SearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function __construct(
protected SearchService $searchService
) {}
public function posts(Request $request): JsonResponse
{
$request->validate([
'q' => 'required|string|min:2',
'category_id' => 'nullable|exists:categories,id',
'author_id' => 'nullable|exists:users,id',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$perPage = $request->input('per_page', 10);
$results = $this->searchService->searchPosts(
$request->input('q'),
$request->only(['category_id', 'author_id']),
$perPage
);
return response()->json([
'data' => PostResource::collection($results),
'pagination' => [
'total' => $results->total(),
'per_page' => $results->perPage(),
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
],
]);
}
public function relatedPosts(Request $request, int $postId): JsonResponse
{
$post = Post::findOrFail($postId);
$relatedPosts = $this->searchService->getRelatedPosts($post);
return response()->json([
'data' => PostResource::collection($relatedPosts),
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\StatisticsService;
use App\Services\ActivityLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class StatisticsController extends Controller
{
public function __construct(
protected StatisticsService $statisticsService,
protected ActivityLogService $activityLogService
) {}
public function posts(): JsonResponse
{
return response()->json([
'data' => $this->statisticsService->getPostStatistics(),
]);
}
public function userActivities(Request $request): JsonResponse
{
return response()->json([
'data' => $this->activityLogService->getUserActivities($request->user()),
]);
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
public function index(): JsonResponse
{
$users = User::all();
return response()->json([
'users' => $users->map(fn($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
])
]);
}
public function update(Request $request, User $user): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => [
'sometimes',
'email',
Rule::unique('users')->ignore($user->id),
],
'password' => 'sometimes|string|min:8',
'role' => 'sometimes|in:admin,author',
]);
if (isset($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
}
$user->update($validated);
return response()->json([
'message' => 'User updated successfully',
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
]
]);
}
public function destroy(User $user): JsonResponse
{
$user->delete();
return response()->json([
'message' => 'User deleted successfully'
]);
}
}

View file

@ -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;

View file

@ -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,

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->isAdmin()) {
return response()->json([
'message' => 'Unauthorized. Admin access required.',
], 403);
}
return $next($request);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ApiRateLimiter
{
public function __construct(
protected RateLimiter $limiter
) {}
public function handle(Request $request, Closure $next): Response
{
$key = $request->user()?->id ?? $request->ip();
if ($this->limiter->tooManyAttempts($key, 60)) { // 60次/分钟
return response()->json([
'error' => 'Too Many Attempts',
'message' => 'Please try again later.',
], 429);
}
$this->limiter->hit($key);
$response = $next($request);
return $response->header('X-RateLimit-Limit', 60)
->header('X-RateLimit-Remaining', $this->limiter->remaining($key, 60));
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ApiVersion
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// 添加 API 版本响应头
$response->headers->set('X-API-Version', 'v1');
return $response;
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class FormatApiResponse
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response instanceof JsonResponse && !$this->isSwaggerRequest($request)) {
$data = $response->getData(true);
$formatted = [
'success' => $response->getStatusCode() < 400,
'message' => $data['message'] ?? ($response->getStatusCode() < 400 ? 'Success' : 'Error'),
];
if (isset($data['data'])) {
$formatted['data'] = $data['data'];
}
if (isset($data['errors'])) {
$formatted['errors'] = $data['errors'];
}
if (isset($data['pagination'])) {
$formatted['pagination'] = $data['pagination'];
}
$response->setData($formatted);
}
return $response;
}
private function isSwaggerRequest(Request $request): bool
{
return str_starts_with($request->path(), 'api/documentation');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class LogApiRequests
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (app()->environment('production')) {
Log::channel('api')->info('API Request', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'user' => $request->user()?->id,
'ip' => $request->ip(),
'status' => $response->status(),
'duration' => defined('LARAVEL_START') ? round((microtime(true) - LARAVEL_START) * 1000, 2) : 0,
]);
}
return $response;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'login' => 'required|string',
'password' => 'required|string',
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->isAdmin();
}
public function rules(): array
{
return [
'account' => 'required|string|max:255|unique:users,account',
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
'role' => 'required|in:admin,author',
];
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Post;
use Illuminate\Foundation\Http\FormRequest;
class ListPostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'authorId' => 'nullable|exists:users,id',
'category' => 'nullable|exists:categories,name',
'search' => 'nullable|string|min:2',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
'sort_by' => 'nullable|in:created_at,title,views',
'sort_order' => 'nullable|in:asc,desc',
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Post;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string',
'summary' => 'nullable|string|max:500',
'category_id' => 'nullable|exists:categories,id',
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Post;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'sometimes|string|max:255',
'content' => 'sometimes|string',
'category_id' => 'nullable|exists:categories,id',
'summary' => 'nullable|string|max:500',
];
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Request;
class ApiResource extends JsonResource
{
public static $wrap = null;
protected bool $success = true;
protected string $message = 'Success';
protected ?array $meta = null;
public function success(bool $success): self
{
$this->success = $success;
return $this;
}
public function message(string $message): self
{
$this->message = $message;
return $this;
}
public function meta(array $meta): self
{
$this->meta = $meta;
return $this;
}
public function toArray(Request $request): array
{
$response = [
'success' => $this->success,
'message' => $this->message,
'data' => parent::toArray($request),
];
if ($this->meta) {
$response['meta'] = $this->meta;
}
return $response;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CategoryResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'posts_count' => $this->when(isset($this->posts_count), $this->posts_count),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CommentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'content' => $this->content,
'author' => new UserResource($this->whenLoaded('author')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Request;
class PaginatedResource extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'pagination' => [
'total' => $this->resource->total(),
'per_page' => $this->resource->perPage(),
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'summary' => $this->summary,
'content' => $this->content,
'views' => $this->views,
'author' => new UserResource($this->whenLoaded('author')),
'category' => new CategoryResource($this->whenLoaded('category')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Pagination\LengthAwarePaginator;
class ApiResponse
{
public static function success($data = null, string $message = 'Success', int $code = 200): JsonResponse
{
$response = [
'success' => true,
'message' => $message,
];
if ($data instanceof JsonResource) {
$response['data'] = $data;
return $data->additional($response)->response()->setStatusCode($code);
}
if ($data instanceof LengthAwarePaginator) {
$response['data'] = $data->items();
$response['pagination'] = [
'total' => $data->total(),
'per_page' => $data->perPage(),
'current_page' => $data->currentPage(),
'last_page' => $data->lastPage(),
];
} else {
$response['data'] = $data;
}
return response()->json($response, $code);
}
public static function error(string $message = 'Error', int $code = 400, array $errors = null): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\PostCreated;
use App\Events\PostUpdated;
use App\Services\CacheService;
use Illuminate\Support\Facades\Cache;
class ClearPostCache
{
public function __construct(
protected CacheService $cacheService
) {}
public function handle(PostCreated|PostUpdated $event): void
{
Cache::tags(['posts', 'search'])->flush();
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
protected $fillable = [
'user_id',
'action',
'model_type',
'model_id',
'description',
'properties',
];
protected $casts = [
'properties' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subject(): MorphTo
{
return $this->morphTo('model');
}
}

24
app/Models/Category.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}

33
app/Models/Comment.php Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Comment extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'content',
'post_id',
'author_id',
];
protected $dates = ['deleted_at'];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}

41
app/Models/Post.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'summary',
'content',
'category_id',
'author_id',
];
protected $dates = ['deleted_at'];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}

View file

@ -1,45 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'account',
'name',
'email',
'password',
'role',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function posts(): HasMany
{
return $this->hasMany(Post::class, 'author_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'author_id');
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isAuthor(): bool
{
return $this->role === 'author';
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
class CategoryPolicy
{
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user): bool
{
return $user->isAdmin();
}
public function delete(User $user): bool
{
return $user->isAdmin();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Comment;
use App\Models\User;
class CommentPolicy
{
public function delete(User $user, Comment $comment): bool
{
return $user->isAdmin() || $comment->author_id === $user->id;
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->isAdmin() || $post->author_id === $user->id;
}
public function delete(User $user, Post $post): bool
{
return $user->isAdmin() || $post->author_id === $user->id;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Response;
class ApiServiceProvider extends ServiceProvider
{
public function boot(): void
{
Response::macro('success', function ($data = null, string $message = 'Success', int $code = 200) {
return Response::json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
});
Response::macro('error', function (string $message = 'Error', int $code = 400, $errors = null) {
$response = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
$response['errors'] = $errors;
}
return Response::json($response, $code);
});
}
}

View file

@ -2,25 +2,24 @@
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
use App\Models\Category;
use App\Models\Post;
use App\Models\Comment;
use App\Policies\CategoryPolicy;
use App\Policies\PostPolicy;
use App\Policies\CommentPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
//
Post::class => PostPolicy::class,
Comment::class => CommentPolicy::class,
Category::class => CategoryPolicy::class,
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
$this->registerPolicies();
}
}

View file

@ -2,35 +2,27 @@
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use App\Events\PostCreated;
use App\Events\PostUpdated;
use App\Listeners\ClearPostCache;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
PostCreated::class => [
ClearPostCache::class,
],
PostUpdated::class => [
ClearPostCache::class,
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*/
public function shouldDiscoverEvents(): bool
{
return false;

View file

@ -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')

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class ActivityLogService
{
public function log(
User $user,
string $action,
Model $subject,
string $description,
array $properties = []
): void {
ActivityLog::create([
'user_id' => $user->id,
'action' => $action,
'model_type' => get_class($subject),
'model_id' => $subject->id,
'description' => $description,
'properties' => $properties,
]);
}
public function getUserActivities(User $user, int $limit = 10): array
{
return ActivityLog::with('subject')
->where('user_id', $user->id)
->latest()
->limit($limit)
->get()
->map(fn($log) => [
'action' => $log->action,
'description' => $log->description,
'created_at' => $log->created_at,
'properties' => $log->properties,
])
->toArray();
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class CacheService
{
protected string $prefix;
protected int $ttl;
public function __construct(string $prefix = '', int $ttl = 3600)
{
$this->prefix = $prefix;
$this->ttl = $ttl;
}
public function remember(string $key, callable $callback)
{
return Cache::remember($this->getCacheKey($key), $this->ttl, $callback);
}
public function forget(string $key): void
{
Cache::forget($this->getCacheKey($key));
}
public function tags(array $tags): self
{
$this->prefix = implode(':', $tags) . ':';
return $this;
}
protected function getCacheKey(string $key): string
{
return $this->prefix . $key;
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Post;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
class PostService
{
public function getPosts(array $filters = []): LengthAwarePaginator
{
$query = Post::with(['author:id,name', 'category:id,name']);
$query = $this->applyFilters($query, $filters);
return $query->latest()->paginate(10);
}
public function createPost(User $user, array $data): Post
{
return $user->posts()->create($data);
}
public function updatePost(Post $post, array $data): Post
{
$post->update($data);
return $post->fresh();
}
protected function applyFilters(Builder $query, array $filters): Builder
{
if (!empty($filters['author_id'])) {
$query->where('author_id', $filters['author_id']);
}
if (!empty($filters['category'])) {
$query->whereHas('category', function ($q) use ($filters) {
$q->where('name', $filters['category']);
});
}
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('title', 'like', "%{$filters['search']}%")
->orWhere('content', 'like', "%{$filters['search']}%");
});
}
return $query;
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class PostViewService
{
protected CacheService $cacheService;
public function __construct(CacheService $cacheService)
{
$this->cacheService = $cacheService;
}
public function recordView(Post $post, string $visitorId): void
{
$cacheKey = "post:{$post->id}:visitor:{$visitorId}";
if (!Cache::has($cacheKey)) {
DB::transaction(function () use ($post) {
$post->increment('views');
});
// 设置24小时内同一访客不重复计数
Cache::put($cacheKey, true, now()->addHours(24));
}
}
public function getPopularPosts(int $limit = 10): array
{
return $this->cacheService
->tags(['posts', 'popular'])
->remember('posts:popular', function () use ($limit) {
return Post::orderByDesc('views')
->limit($limit)
->get()
->map(fn($post) => [
'id' => $post->id,
'title' => $post->title,
'views' => $post->views,
])
->toArray();
});
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class SearchService
{
protected CacheService $cacheService;
public function __construct(CacheService $cacheService)
{
$this->cacheService = $cacheService;
}
public function searchPosts(string $query, array $filters = [], int $perPage = 10): LengthAwarePaginator
{
return $this->cacheService
->tags(['search', 'posts'])
->remember("posts:search:{$query}", function () use ($query, $filters, $perPage) {
return Post::with(['author:id,name', 'category:id,name'])
->where(function ($q) use ($query) {
$q->where('title', 'like', "%{$query}%")
->orWhere('content', 'like', "%{$query}%")
->orWhere('summary', 'like', "%{$query}%");
})
->when(!empty($filters['category_id']), function ($q) use ($filters) {
$q->where('category_id', $filters['category_id']);
})
->when(!empty($filters['author_id']), function ($q) use ($filters) {
$q->where('author_id', $filters['author_id']);
})
->latest()
->paginate($perPage);
});
}
public function getRelatedPosts(Post $post, int $limit = 5): Collection
{
return $this->cacheService
->tags(['posts', 'related'])
->remember("posts:{$post->id}:related", function () use ($post, $limit) {
return Post::where('category_id', $post->category_id)
->where('id', '!=', $post->id)
->take($limit)
->latest()
->get();
});
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Post;
use App\Models\User;
use App\Models\Category;
use Illuminate\Support\Facades\DB;
class StatisticsService
{
protected CacheService $cacheService;
public function __construct(CacheService $cacheService)
{
$this->cacheService = $cacheService;
}
public function getPostStatistics(): array
{
return $this->cacheService->remember('statistics:posts', function () {
return [
'total_posts' => Post::count(),
'posts_by_category' => $this->getPostsByCategory(),
'posts_by_author' => $this->getPostsByAuthor(),
'most_commented' => $this->getMostCommentedPosts(),
'recent_posts' => $this->getRecentPosts(),
];
});
}
protected function getPostsByCategory(): array
{
return Category::withCount('posts')
->orderByDesc('posts_count')
->get()
->map(fn($category) => [
'name' => $category->name,
'count' => $category->posts_count,
])
->toArray();
}
protected function getPostsByAuthor(): array
{
return User::withCount('posts')
->orderByDesc('posts_count')
->limit(10)
->get()
->map(fn($user) => [
'name' => $user->name,
'count' => $user->posts_count,
])
->toArray();
}
protected function getMostCommentedPosts(): array
{
return Post::withCount('comments')
->orderByDesc('comments_count')
->limit(5)
->get()
->map(fn($post) => [
'title' => $post->title,
'comments_count' => $post->comments_count,
])
->toArray();
}
protected function getRecentPosts(): array
{
return Post::latest()
->limit(5)
->get()
->map(fn($post) => [
'title' => $post->title,
'created_at' => $post->created_at,
])
->toArray();
}
}

17
config/api.php Normal file
View file

@ -0,0 +1,17 @@
return [
'throttle' => [
'enabled' => env('API_THROTTLE_ENABLED', true),
'limit' => env('API_THROTTLE_LIMIT', 60),
'decay_minutes' => env('API_THROTTLE_DECAY_MINUTES', 1),
],
'cache' => [
'ttl' => env('API_CACHE_TTL', 3600),
'prefix' => env('API_CACHE_PREFIX', 'api_'),
],
'pagination' => [
'default_limit' => env('API_PAGINATION_LIMIT', 10),
'max_limit' => env('API_PAGINATION_MAX_LIMIT', 100),
],
];

View file

@ -168,6 +168,7 @@
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\ApiServiceProvider::class,
])->toArray(),
/*

View file

@ -53,6 +53,7 @@
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
'ttl' => env('CACHE_TTL', 3600),
],
'memcached' => [
@ -106,6 +107,6 @@
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
'prefix' => env('CACHE_PREFIX', 'blog_api_'),
];

29
config/l5-swagger.php Normal file
View file

@ -0,0 +1,29 @@
<?php
return [
'default' => 'default',
'documentations' => [
'default' => [
'api' => [
'title' => 'Blog API Documentation',
],
'routes' => [
'api' => 'api/documentation',
],
'paths' => [
'use_absolute_path' => env('L5_SWAGGER_USE_ABSOLUTE_PATH', true),
'docs_json' => 'api-docs.json',
'docs_yaml' => 'api-docs.yaml',
'format_to_use_for_docs' => env('L5_FORMAT_TO_USE_FOR_DOCS', 'json'),
],
],
],
'security' => [
'api_key' => [
'type' => 'apiKey',
'description' => 'Authorization token',
'name' => 'Authorization',
'in' => 'header',
],
],
];

View file

@ -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,
],
],
];

View file

@ -1,83 +1,20 @@
<?php
use Laravel\Sanctum\Sanctum;
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'expiration' => env('SANCTUM_EXPIRATION', null),
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
];

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class CommentFactory extends Factory
{
public function definition(): array
{
return [
'content' => fake()->paragraph(),
'post_id' => Post::factory(),
'author_id' => User::factory(),
];
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'summary' => fake()->paragraph(),
'content' => fake()->paragraphs(3, true),
'author_id' => User::factory(),
'category_id' => Category::factory(),
];
}
}

View file

@ -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',
];
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->enum('role', ['admin', 'author'])->default('author')->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View file

@ -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();
});
}
};

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('action');
$table->string('model_type');
$table->unsignedBigInteger('model_id');
$table->string('description');
$table->json('properties')->nullable();
$table->timestamps();
$table->index(['model_type', 'model_id']);
});
}
public function down(): void
{
Schema::dropIfExists('activity_logs');
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->unsignedInteger('views')->default(0)->after('category_id');
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('views');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('account')->unique()->after('id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('account');
});
}
};

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Category;
use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['name' => 'Technology', 'description' => 'Tech related posts'],
['name' => 'Travel', 'description' => 'Travel experiences'],
['name' => 'Food', 'description' => 'Food and cooking'],
['name' => 'Lifestyle', 'description' => 'Lifestyle topics'],
];
foreach ($categories as $category) {
Category::create($category);
}
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Comment;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Seeder;
class CommentSeeder extends Seeder
{
public function run(): void
{
$posts = Post::all();
$users = User::all();
foreach ($posts as $post) {
Comment::factory()
->count(rand(2, 5))
->create([
'post_id' => $post->id,
'author_id' => $users->random()->id,
]);
}
}
}

View file

@ -1,22 +1,20 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// \App\Models\User::factory(10)->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
$this->call([
UserSeeder::class,
CategorySeeder::class,
PostSeeder::class,
CommentSeeder::class,
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Post;
use App\Models\User;
use App\Models\Category;
use Illuminate\Database\Seeder;
class PostSeeder extends Seeder
{
public function run(): void
{
$authors = User::where('role', 'author')->get();
$categories = Category::all();
foreach ($authors as $author) {
Post::factory()
->count(3)
->create([
'author_id' => $author->id,
'category_id' => $categories->random()->id,
]);
}
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class UserSeeder extends Seeder
{
public function run(): void
{
// Create admin user
User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => Hash::make('password'),
'role' => 'admin',
]);
// Create some author users
User::factory(5)->create([
'role' => 'author',
]);
}
}

View file

@ -0,0 +1,18 @@
-- 创建超级管理员账号
INSERT INTO `users` (
`account`,
`name`,
`email`,
`password`,
`role`,
`created_at`,
`updated_at`
) VALUES (
'admin',
'Super Admin',
'admin@example.com',
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- 密码: password
'admin',
NOW(),
NOW()
);

View file

@ -1,6 +1,12 @@
<?php
use Illuminate\Http\Request;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\CategoryController;
use App\Http\Controllers\Api\CommentController;
use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\SearchController;
use App\Http\Controllers\Api\StatisticsController;
use Illuminate\Support\Facades\Route;
/*
@ -17,3 +23,55 @@
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// 公开路由
Route::post('/auth/login', [AuthController::class, 'login']);
// 需要认证的路由
Route::middleware('auth:sanctum')->group(function () {
// 认证相关
Route::post('/auth/logout', [AuthController::class, 'logout']);
// 仅管理员可访问的路由
Route::middleware('admin')->group(function () {
Route::post('/auth/register', [AuthController::class, 'register']);
Route::apiResource('users', UserController::class);
});
// 文章相关
Route::apiResource('posts', PostController::class);
// 评论相关
Route::apiResource('posts.comments', CommentController::class)
->shallow();
// 分类相关
Route::apiResource('categories', CategoryController::class);
// 搜索相关路由
Route::prefix('search')->group(function () {
Route::get('posts', [SearchController::class, 'posts']);
Route::get('posts/{post}/related', [SearchController::class, 'relatedPosts']);
});
// 统计相关路由
Route::middleware('auth:sanctum')->prefix('statistics')->group(function () {
Route::get('posts', [StatisticsController::class, 'posts']);
Route::get('user-activities', [StatisticsController::class, 'userActivities']);
});
// 文章阅读量统计
Route::get('posts/popular', [PostController::class, 'popular']);
// 分类相关路由
Route::get('categories/{category}/posts', [CategoryController::class, 'posts']);
});
// 添加 API 版本信息路由
Route::get('/', function () {
return response()->json([
'name' => 'Blog API',
'version' => '1.0',
'documentation' => url('/api/documentation'),
]);
});

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_login_with_email(): void
{
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$response = $this->postJson('/api/auth/login', [
'login' => 'test@example.com',
'password' => 'password',
]);
$response->assertStatus(200)
->assertJsonStructure([
'token',
'user' => [
'id',
'name',
'email',
'role',
],
]);
}
public function test_user_can_login_with_account(): void
{
$user = User::factory()->create([
'account' => 'testuser',
'password' => bcrypt('password'),
]);
$response = $this->postJson('/api/auth/login', [
'login' => 'testuser',
'password' => 'password',
]);
$response->assertStatus(200)
->assertJsonStructure([
'token',
'user' => [
'id',
'name',
'email',
'role',
],
]);
}
public function test_user_cannot_login_with_incorrect_credentials(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/auth/login', [
'login' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(422);
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CategoryTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_create_category(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->postJson('/api/categories', [
'name' => 'New Category',
'description' => 'Category Description',
]);
$response->assertStatus(200)
->assertJson([
'message' => 'Category created successfully',
]);
$this->assertDatabaseHas('categories', [
'name' => 'New Category',
]);
}
public function test_author_cannot_create_category(): void
{
$author = User::factory()->create(['role' => 'author']);
$response = $this->actingAs($author)
->postJson('/api/categories', [
'name' => 'New Category',
'description' => 'Category Description',
]);
$response->assertStatus(403);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Comment;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CommentTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_add_comment(): void
{
$user = User::factory()->create();
$post = Post::factory()->create();
$response = $this->actingAs($user)
->postJson("/api/posts/{$post->id}/comments", [
'content' => 'Test Comment',
]);
$response->assertStatus(200)
->assertJson([
'message' => 'Comment added successfully',
]);
$this->assertDatabaseHas('comments', [
'content' => 'Test Comment',
'post_id' => $post->id,
'author_id' => $user->id,
]);
}
public function test_user_can_delete_own_comment(): void
{
$user = User::factory()->create();
$comment = Comment::factory()->create([
'author_id' => $user->id,
]);
$response = $this->actingAs($user)
->deleteJson("/api/comments/{$comment->id}");
$response->assertStatus(200)
->assertJson([
'message' => 'Comment deleted successfully',
]);
$this->assertDatabaseMissing('comments', [
'id' => $comment->id,
]);
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory(5)->create();
$response = $this->actingAs($user)
->getJson('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'posts' => [
'*' => [
'id',
'title',
'summary',
'author',
],
],
'pagination',
]);
}
public function test_author_can_create_post(): void
{
$user = User::factory()->create(['role' => 'author']);
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Test Post',
'content' => 'Test Content',
'summary' => 'Test Summary',
]);
$response->assertStatus(200)
->assertJson([
'message' => 'Post created successfully',
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'author_id' => $user->id,
]);
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostViewTest extends TestCase
{
use RefreshDatabase;
public function test_post_view_count_increases(): void
{
$post = Post::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$this->assertEquals(1, $post->fresh()->views);
}
public function test_same_ip_does_not_increase_view_count_within_24_hours(): void
{
$post = Post::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$this->assertEquals(1, $post->fresh()->views);
}
public function test_can_get_popular_posts(): void
{
$posts = Post::factory()->count(5)->create();
$user = User::factory()->create();
foreach ($posts as $index => $post) {
for ($i = 0; $i <= $index; $i++) {
$post->increment('views');
}
}
$response = $this->actingAs($user)
->getJson('/api/posts/popular');
$response->assertStatus(200)
->assertJsonCount(5, 'data')
->assertJsonPath('data.0.views', 4);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Tests\Traits;
use App\Http\Middleware\ApiRateLimiter;
use App\Http\Middleware\LogApiRequests;
trait WithoutMiddleware
{
protected function disableMiddleware(): void
{
$this->withoutMiddleware([
ApiRateLimiter::class,
LogApiRequests::class,
]);
}
}