From c6d6534b6360f12eed1bc07fac5eeab486812fe7 Mon Sep 17 00:00:00 2001 From: ROOG Date: Sun, 14 Dec 2025 17:49:08 +0800 Subject: [PATCH] =?UTF-8?q?main:=20=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E4=BC=9A=E8=AF=9D=E5=8A=9F=E8=83=BD=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加用户管理功能的测试,包括创建、更新、停用、激活用户及用户登录 JWT 测试 - 提供用户管理相关的请求验证类与控制器 - 引入 CORS 配置信息,支持跨域请求 - 添加数据库播种器以便创建根用户 - 配置 API 默认使用 JWT 认证 - 添加聊天会话和消息的模型、迁移文件及关联功能 --- .env.example | 5 +- README.md | 3 + app/Enums/ChatSessionStatus.php | 15 + app/Exceptions/ChatSessionStatusException.php | 9 + app/Http/Controllers/AuthController.php | 40 ++ .../Controllers/ChatSessionController.php | 51 +++ app/Http/Controllers/UserController.php | 54 +++ app/Http/Requests/AppendMessageRequest.php | 35 ++ app/Http/Requests/CreateSessionRequest.php | 23 ++ app/Http/Requests/LoginRequest.php | 24 ++ app/Http/Requests/StoreUserRequest.php | 25 ++ app/Http/Requests/UpdateUserRequest.php | 32 ++ app/Http/Resources/MessageResource.php | 31 ++ app/Http/Resources/UserResource.php | 27 ++ app/Models/ChatSession.php | 48 +++ app/Models/Message.php | 52 +++ app/Models/User.php | 15 +- app/Services/ChatService.php | 132 +++++++ bootstrap/app.php | 10 +- composer.json | 3 +- composer.lock | 306 ++++++++++++++- config/auth.php | 7 +- config/cors.php | 34 ++ config/jwt.php | 321 +++++++++++++++ config/session.php | 2 +- database/factories/UserFactory.php | 1 + .../0001_01_01_000000_create_users_table.php | 1 + .../2025_02_14_000003_create_chat_tables.php | 43 +++ database/seeders/DatabaseSeeder.php | 16 +- docs/user/user-api.md | 84 ++++ docs/user/user-openapi.yaml | 365 ++++++++++++++++++ routes/api.php | 27 ++ routes/web.php | 4 +- tests/Feature/ChatSessionTest.php | 108 ++++++ tests/Feature/CorsTest.php | 21 + tests/Feature/UserManagementTest.php | 161 ++++++++ 36 files changed, 2119 insertions(+), 16 deletions(-) create mode 100644 app/Enums/ChatSessionStatus.php create mode 100644 app/Exceptions/ChatSessionStatusException.php create mode 100644 app/Http/Controllers/AuthController.php create mode 100644 app/Http/Controllers/ChatSessionController.php create mode 100644 app/Http/Controllers/UserController.php create mode 100644 app/Http/Requests/AppendMessageRequest.php create mode 100644 app/Http/Requests/CreateSessionRequest.php create mode 100644 app/Http/Requests/LoginRequest.php create mode 100644 app/Http/Requests/StoreUserRequest.php create mode 100644 app/Http/Requests/UpdateUserRequest.php create mode 100644 app/Http/Resources/MessageResource.php create mode 100644 app/Http/Resources/UserResource.php create mode 100644 app/Models/ChatSession.php create mode 100644 app/Models/Message.php create mode 100644 app/Services/ChatService.php create mode 100644 config/cors.php create mode 100644 config/jwt.php create mode 100644 database/migrations/2025_02_14_000003_create_chat_tables.php create mode 100644 docs/user/user-api.md create mode 100644 docs/user/user-openapi.yaml create mode 100644 routes/api.php create mode 100644 tests/Feature/ChatSessionTest.php create mode 100644 tests/Feature/CorsTest.php create mode 100644 tests/Feature/UserManagementTest.php diff --git a/.env.example b/.env.example index afc4e86..1e96ec5 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ OCTANE_SERVER=frankenphp APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US +AUTH_GUARD=api APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database @@ -28,7 +29,7 @@ DB_DATABASE=ars_backend DB_USERNAME=ars DB_PASSWORD=secret -SESSION_DRIVER=database +SESSION_DRIVER=array SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -47,6 +48,8 @@ REDIS_CLIENT=phpredis REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 +JWT_SECRET= +CORS_ALLOWED_ORIGINS=http://localhost:5173 MAIL_MAILER=log MAIL_SCHEME=null diff --git a/README.md b/README.md index a7114fb..a116730 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Notes: - Config sets `OCTANE_SERVER=frankenphp` by default (see `.env` and `config/octane.php`). - PostgreSQL service: host `pgsql`, port `5432`, database `ars_backend`, user `ars`, password `secret` (see `.env.example`). - Redis service: host `redis`, port `6379`, default no password, client `phpredis`. +- API 默认使用 JWT:`AUTH_GUARD=api`,会话驱动为 `array`(无状态)。首次运行如需重置密钥:`docker compose run --rm --entrypoint=php app artisan jwt:secret --force`。 +- API 说明文档:`docs/user/user-api.md`(中文);OpenAPI/Swagger 规范:`docs/user/user-openapi.yaml`(可导入 Swagger UI / Postman)。 +- CORS:通过全局中间件开启,允许域名由环境变量 `CORS_ALLOWED_ORIGINS` 配置(默认 `http://localhost:5173`,多域名用逗号分隔)。 - 项目沟通与自然语言默认使用中文。 ## About Laravel diff --git a/app/Enums/ChatSessionStatus.php b/app/Enums/ChatSessionStatus.php new file mode 100644 index 0000000..f2d12fb --- /dev/null +++ b/app/Enums/ChatSessionStatus.php @@ -0,0 +1,15 @@ +validated(); + + $user = User::whereEmail($credentials['email'])->first(); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + return response()->json(['message' => '凭证无效'], 401); + } + + if (! $user->is_active) { + return response()->json(['message' => '用户已停用'], 403); + } + + $token = auth('api')->login($user); + + return response()->json([ + 'token' => $token, + 'token_type' => 'bearer', + 'expires_in' => auth('api')->factory()->getTTL() * 60, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'is_active' => $user->is_active, + ], + ]); + } +} diff --git a/app/Http/Controllers/ChatSessionController.php b/app/Http/Controllers/ChatSessionController.php new file mode 100644 index 0000000..21f429e --- /dev/null +++ b/app/Http/Controllers/ChatSessionController.php @@ -0,0 +1,51 @@ +service->createSession($request->input('session_name')); + + return response()->json($session, 201); + } + + public function append(string $sessionId, AppendMessageRequest $request): JsonResponse + { + try { + $message = $this->service->appendMessage([ + 'session_id' => $sessionId, + ...$request->validated(), + ]); + } catch (ChatSessionStatusException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } + + return (new MessageResource($message))->response()->setStatusCode(201); + } + + public function listMessages(Request $request, string $sessionId): JsonResponse + { + $afterSeq = (int) $request->query('after_seq', 0); + $limit = (int) $request->query('limit', 50); + $limit = $limit > 0 && $limit <= 200 ? $limit : 50; + + $messages = $this->service->listMessagesBySeq($sessionId, $afterSeq, $limit); + + return MessageResource::collection($messages)->response(); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..68c9ea2 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,54 @@ +query('per_page', 15); + $perPage = $perPage > 0 && $perPage <= 100 ? $perPage : 15; + + $users = User::orderBy('id')->paginate($perPage); + + return UserResource::collection($users)->response(); + } + + public function store(StoreUserRequest $request): JsonResponse + { + $payload = $request->validated(); + $payload['is_active'] = true; + + $user = User::create($payload); + + return (new UserResource($user))->response()->setStatusCode(201); + } + + public function update(UpdateUserRequest $request, User $user): JsonResponse + { + $user->update($request->validated()); + + return (new UserResource($user))->response(); + } + + public function deactivate(User $user): JsonResponse + { + $user->update(['is_active' => false]); + + return (new UserResource($user))->response(); + } + + public function activate(User $user): JsonResponse + { + $user->update(['is_active' => true]); + + return (new UserResource($user))->response(); + } +} diff --git a/app/Http/Requests/AppendMessageRequest.php b/app/Http/Requests/AppendMessageRequest.php new file mode 100644 index 0000000..31fefe1 --- /dev/null +++ b/app/Http/Requests/AppendMessageRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'role' => ['required', 'string', Rule::in([ + Message::ROLE_USER, + Message::ROLE_AGENT, + Message::ROLE_TOOL, + Message::ROLE_SYSTEM, + ])], + 'type' => ['required', 'string', 'max:64'], + 'content' => ['nullable', 'string'], + 'payload' => ['nullable', 'array'], + 'reply_to' => ['nullable', 'uuid'], + 'dedupe_key' => ['nullable', 'string', 'max:128'], + ]; + } +} diff --git a/app/Http/Requests/CreateSessionRequest.php b/app/Http/Requests/CreateSessionRequest.php new file mode 100644 index 0000000..0381095 --- /dev/null +++ b/app/Http/Requests/CreateSessionRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'session_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..abf2e09 --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php new file mode 100644 index 0000000..56a0935 --- /dev/null +++ b/app/Http/Requests/StoreUserRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8'], + ]; + } +} diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..4cd17d2 --- /dev/null +++ b/app/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'email' => [ + 'sometimes', + 'required', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($this->route('user')), + ], + 'password' => ['sometimes', 'required', 'string', 'min:8'], + ]; + } +} diff --git a/app/Http/Resources/MessageResource.php b/app/Http/Resources/MessageResource.php new file mode 100644 index 0000000..b9113ad --- /dev/null +++ b/app/Http/Resources/MessageResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'message_id' => $this->message_id, + 'session_id' => $this->session_id, + 'seq' => $this->seq, + 'role' => $this->role, + 'type' => $this->type, + 'content' => $this->content, + 'payload' => $this->payload, + 'reply_to' => $this->reply_to, + 'dedupe_key' => $this->dedupe_key, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..816ffcb --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/ChatSession.php b/app/Models/ChatSession.php new file mode 100644 index 0000000..7d9cae5 --- /dev/null +++ b/app/Models/ChatSession.php @@ -0,0 +1,48 @@ + 'integer', + ]; + } + + public function messages(): HasMany + { + return $this->hasMany(Message::class, 'session_id', 'session_id'); + } + + public function isClosed(): bool + { + return $this->status === ChatSessionStatus::CLOSED; + } + + public function isLocked(): bool + { + return $this->status === ChatSessionStatus::LOCKED; + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..0a7dbbc --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,52 @@ + 'array', + 'seq' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function session(): BelongsTo + { + return $this->belongsTo(ChatSession::class, 'session_id', 'session_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..b54836d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,8 +6,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject; -class User extends Authenticatable +class User extends Authenticatable implements JWTSubject { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; @@ -21,6 +22,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'is_active', ]; /** @@ -43,6 +45,17 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'is_active' => 'boolean', ]; } + + public function getJWTIdentifier(): mixed + { + return $this->getKey(); + } + + public function getJWTCustomClaims(): array + { + return []; + } } diff --git a/app/Services/ChatService.php b/app/Services/ChatService.php new file mode 100644 index 0000000..f0dbf7d --- /dev/null +++ b/app/Services/ChatService.php @@ -0,0 +1,132 @@ + (string) Str::uuid(), + 'session_name' => $name, + 'status' => ChatSessionStatus::OPEN, + 'last_seq' => 0, + ]); + } + + public function getSession(string $sessionId): ChatSession + { + return ChatSession::query()->whereKey($sessionId)->firstOrFail(); + } + + /** + * @param array $dto + */ + public function appendMessage(array $dto): Message + { + return DB::transaction(function () use ($dto) { + /** @var ChatSession $session */ + $session = ChatSession::query() + ->whereKey($dto['session_id']) + ->lockForUpdate() + ->firstOrFail(); + + $this->ensureCanAppend($session, $dto['role'], $dto['type']); + + $dedupeKey = $dto['dedupe_key'] ?? null; + if ($dedupeKey) { + $existing = Message::query() + ->where('session_id', $session->session_id) + ->where('dedupe_key', $dedupeKey) + ->first(); + + if ($existing) { + return $existing; + } + } + + $newSeq = $session->last_seq + 1; + + $message = new Message([ + 'message_id' => (string) Str::uuid(), + 'session_id' => $session->session_id, + 'role' => $dto['role'], + 'type' => $dto['type'], + 'content' => $dto['content'] ?? null, + 'payload' => $dto['payload'] ?? null, + 'reply_to' => $dto['reply_to'] ?? null, + 'dedupe_key' => $dedupeKey, + 'seq' => $newSeq, + 'created_at' => now(), + ]); + + try { + $message->save(); + } catch (QueryException $e) { + if ($this->isUniqueConstraint($e) && $dedupeKey) { + $existing = Message::query() + ->where('session_id', $session->session_id) + ->where('dedupe_key', $dedupeKey) + ->first(); + + if ($existing) { + return $existing; + } + } + + throw $e; + } + + $session->update([ + 'last_seq' => $newSeq, + 'updated_at' => now(), + ]); + + return $message; + }); + } + + public function listMessagesBySeq(string $sessionId, int $afterSeq, int $limit = 50): Collection + { + $this->getSession($sessionId); // ensure exists + + return Message::query() + ->where('session_id', $sessionId) + ->where('seq', '>', $afterSeq) + ->orderBy('seq') + ->limit($limit) + ->get(); + } + + private function ensureCanAppend(ChatSession $session, string $role, string $type): void + { + if ($session->status === ChatSessionStatus::CLOSED) { + $allowed = $role === Message::ROLE_SYSTEM && in_array($type, ['run.status', 'error'], true); + if (! $allowed) { + throw new ChatSessionStatusException('Session is closed'); + } + } + + if ($session->status === ChatSessionStatus::LOCKED) { + if ($role === Message::ROLE_USER && $type === 'user.prompt') { + throw new ChatSessionStatusException('Session is locked'); + } + } + } + + private function isUniqueConstraint(QueryException $e): bool + { + $sqlState = $e->getCode() ?: ($e->errorInfo[0] ?? null); + + return $sqlState === '23505'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..2078913 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,15 +3,23 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Middleware\HandleCors; +use PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate as JwtAuthenticate; +use PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken as JwtRefreshToken; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->append(HandleCors::class); + $middleware->alias([ + 'auth.jwt' => JwtAuthenticate::class, + 'auth.jwt.refresh' => JwtRefreshToken::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 8d8be75..4c789a1 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "php": "^8.2", "laravel/framework": "^12.0", "laravel/octane": "^2.13", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "php-open-source-saver/jwt-auth": "^2.8" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index a071704..7b49452 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "814e29ecc172cfe807c128b7df10ae19", + "content-hash": "79f1e234537460fac440cd9aa68d3e6b", "packages": [ { "name": "brick/math", @@ -1638,6 +1638,79 @@ }, "time": "2025-11-20T16:29:12+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.0", @@ -2300,6 +2373,73 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "support": { + "issues": "https://github.com/namshi/jose/issues", + "source": "https://github.com/namshi/jose/tree/master" + }, + "time": "2016-12-05T07:27:31+00:00" + }, { "name": "nesbot/carbon", "version": "3.11.0", @@ -2704,6 +2844,102 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "php-open-source-saver/jwt-auth", + "version": "v2.8.3", + "source": { + "type": "git", + "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", + "reference": "563f7dc025f48b9ecbacc271da509bbb4c6b3b23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/563f7dc025f48b9ecbacc271da509bbb4c6b3b23", + "reference": "563f7dc025f48b9ecbacc271da509bbb4c6b3b23", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/auth": "^10|^11|^12", + "illuminate/contracts": "^10|^11|^12", + "illuminate/http": "^10|^11|^12", + "illuminate/support": "^10|^11|^12", + "lcobucci/jwt": "^5.4", + "namshi/jose": "^7.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "illuminate/console": "^10|^11|^12", + "illuminate/routing": "^10|^11|^12", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8|^9|^10", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5|^11" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", + "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" + }, + "providers": [ + "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" + ] + }, + "branch-alias": { + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + }, + { + "name": "Max Snow", + "email": "contact@maxsnow.me", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2025-10-15T12:02:51+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -4887,6 +5123,74 @@ ], "time": "2024-12-23T08:48:59+00:00" }, + { + "name": "symfony/polyfill-php56", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + }, + "branch-alias": { + "dev-main": "1.20-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.33.0", diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..f8ce3e9 100644 --- a/config/auth.php +++ b/config/auth.php @@ -14,7 +14,7 @@ return [ */ 'defaults' => [ - 'guard' => env('AUTH_GUARD', 'web'), + 'guard' => env('AUTH_GUARD', 'api'), 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), ], @@ -40,6 +40,11 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + 'hash' => false, + ], ], /* diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..876611a --- /dev/null +++ b/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'up', 'octane'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => array_filter(array_map('trim', explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost:5173')))), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, + +]; diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 0000000..5f817b7 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,321 @@ + env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT Authentication Keys + |-------------------------------------------------------------------------- + | + | The algorithm you are using, will determine whether your tokens are + | signed with a random string (defined in `JWT_SECRET`) or using the + | following public & private keys. + | + | Symmetric Algorithms: + | HS256, HS384 & HS512 will use `JWT_SECRET`. + | + | Asymmetric Algorithms: + | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | Public Key + |-------------------------------------------------------------------------- + | + | A path or resource to your public key. + | + | E.g. 'file://path/to/public/key' + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Private Key + |-------------------------------------------------------------------------- + | + | A path or resource to your private key. + | + | E.g. 'file://path/to/private/key' + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passphrase + |-------------------------------------------------------------------------- + | + | The passphrase for your private key. Can be null if none set. + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token will be valid for. + | Defaults to 1 hour. + | + | You can also set this to null, to yield a never expiring token. + | Some people may want this behaviour for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | + */ + + 'ttl' => (int) env('JWT_TTL', 60), + + /* + |-------------------------------------------------------------------------- + | Refresh time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token can be refreshed within. + | This defines the refresh window, during which the user can refresh their token + | before re-authentication is required. + | + | By default, a refresh will NOT issue a new "iat" (issued at) timestamp. If changed + | to true, each refresh will issue a new "iat" timestamp, extending the refresh + | period from the most recent refresh. This results in a rolling refresh + | + | To retain a fluid refresh window from the last refresh action (i.e., the behavior between + | version 2.5.0 and 2.8.2), set "refresh_iat" to true. With this setting, the refresh + | window will renew with each subsequent refresh. + | + | The refresh ttl defaults to 2 weeks. + | + | You can also set this to null, to yield an infinite refresh time. + | Some may want this instead of never expiring tokens for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | + */ + + 'refresh_iat' => env('JWT_REFRESH_IAT', false), + 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20160), + + /* + |-------------------------------------------------------------------------- + | JWT hashing algorithm + |-------------------------------------------------------------------------- + | + | Specify the hashing algorithm that will be used to sign the token. + | + | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL + | for possible values. + | + */ + + 'algo' => env('JWT_ALGO', 'HS256'), + + /* + |-------------------------------------------------------------------------- + | Required Claims + |-------------------------------------------------------------------------- + | + | Specify the required claims that must exist in any token. + | A TokenInvalidException will be thrown if any of these claims are not + | present in the payload. + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | Persistent Claims + |-------------------------------------------------------------------------- + | + | Specify the claim keys to be persisted when refreshing a token. + | `sub` and `iat` will automatically be persisted, in + | addition to the these claims. + | + | Note: If a claim does not exist then it will be ignored. + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | Lock Subject + |-------------------------------------------------------------------------- + | + | This will determine whether a `prv` claim is automatically added to + | the token. The purpose of this is to ensure that if you have multiple + | authentication models e.g. `App\User` & `App\OtherPerson`, then we + | should prevent one authentication request from impersonating another, + | if 2 tokens happen to have the same id across the 2 different models. + | + | Under specific circumstances, you may want to disable this behaviour + | e.g. if you only have one authentication model, then you would save + | a little on token size. + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | Leeway + |-------------------------------------------------------------------------- + | + | This property gives the jwt timestamp claims some "leeway". + | Meaning that if you have any unavoidable slight clock skew on + | any of your servers then this will afford you some level of cushioning. + | + | This applies to the claims `iat`, `nbf` and `exp`. + | + | Specify in seconds - only if you know you need it. + | + */ + + 'leeway' => (int) env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Enabled + |-------------------------------------------------------------------------- + | + | In order to invalidate tokens, you must have the blacklist enabled. + | If you do not want or need this functionality, then set this to false. + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), + + /* + | ------------------------------------------------------------------------- + | Blacklist Grace Period + | ------------------------------------------------------------------------- + | + | When multiple concurrent requests are made with the same JWT, + | it is possible that some of them fail, due to token regeneration + | on every request. + | + | Set grace period in seconds to prevent parallel request failure. + | + */ + + 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | Show blacklisted token option + |-------------------------------------------------------------------------- + | + | Specify if you want to show black listed token exception on the laravel logs. + | + */ + + 'show_black_list_exception' => env('JWT_SHOW_BLACKLIST_EXCEPTION', true), + + /* + |-------------------------------------------------------------------------- + | Cookies encryption + |-------------------------------------------------------------------------- + | + | By default Laravel encrypt cookies for security reason. + | If you decide to not decrypt cookies, you will have to configure Laravel + | to not encrypt your cookie token by adding its name into the $except + | array available in the middleware "EncryptCookies" provided by Laravel. + | see https://laravel.com/docs/master/responses#cookies-and-encryption + | for details. + | + | Set it to true if you want to decrypt cookies. + | + */ + + 'decrypt_cookies' => false, + + /* + |-------------------------------------------------------------------------- + | Cookie key name + |-------------------------------------------------------------------------- + | + | Specify the cookie key name that you would like to use for the cookie token. + | + */ + + 'cookie_key_name' => 'token', + + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | Specify the various providers used throughout the package. + | + */ + + 'providers' => [ + /* + |-------------------------------------------------------------------------- + | JWT Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to create and decode the tokens. + | + */ + + 'jwt' => PHPOpenSourceSaver\JWTAuth\Providers\JWT\Lcobucci::class, + + /* + |-------------------------------------------------------------------------- + | Authentication Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to authenticate users. + | + */ + + 'auth' => PHPOpenSourceSaver\JWTAuth\Providers\Auth\Illuminate::class, + + /* + |-------------------------------------------------------------------------- + | Storage Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to store tokens in the blacklist. + | + */ + + 'storage' => PHPOpenSourceSaver\JWTAuth\Providers\Storage\Illuminate::class, + ], +]; diff --git a/config/session.php b/config/session.php index 5b541b7..db8437b 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ return [ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'array'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..d467b1a 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ class UserFactory extends Factory 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'is_active' => true, ]; } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..2ac1698 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -17,6 +17,7 @@ return new class extends Migration $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->boolean('is_active')->default(true); $table->rememberToken(); $table->timestamps(); }); diff --git a/database/migrations/2025_02_14_000003_create_chat_tables.php b/database/migrations/2025_02_14_000003_create_chat_tables.php new file mode 100644 index 0000000..0045e31 --- /dev/null +++ b/database/migrations/2025_02_14_000003_create_chat_tables.php @@ -0,0 +1,43 @@ +uuid('session_id')->primary(); + $table->string('session_name', 255)->nullable(); + $table->string('status', 16)->default('OPEN'); + $table->unsignedBigInteger('last_seq')->default(0); + $table->timestamps(); + }); + + Schema::create('messages', function (Blueprint $table) { + $table->uuid('message_id')->primary(); + $table->uuid('session_id'); + $table->string('role', 32); + $table->string('type', 64); + $table->text('content')->nullable(); + $table->jsonb('payload')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->unsignedBigInteger('seq'); + $table->uuid('reply_to')->nullable(); + $table->string('dedupe_key', 128)->nullable(); + + $table->foreign('session_id')->references('session_id')->on('chat_sessions')->onDelete('cascade'); + $table->unique(['session_id', 'seq']); + $table->unique(['session_id', 'dedupe_key']); + $table->index(['session_id', 'seq']); + }); + } + + public function down(): void + { + Schema::dropIfExists('messages'); + Schema::dropIfExists('chat_sessions'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..c04e78c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -15,11 +16,14 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + User::updateOrCreate( + ['email' => 'root@example.com'], + [ + 'name' => 'root', + 'password' => Hash::make('Root@123456'), + 'is_active' => true, + 'email_verified_at' => now(), + ], + ); } } diff --git a/docs/user/user-api.md b/docs/user/user-api.md new file mode 100644 index 0000000..0214785 --- /dev/null +++ b/docs/user/user-api.md @@ -0,0 +1,84 @@ +# 接口文档(JWT,无状态 API) + +基地址:`http://localhost:8000/api`(容器默认映射 8000 端口) +自然语言:中文 + +- 认证方式:在请求头添加 `Authorization: Bearer {token}`。 +- 默认账号(来自 `php artisan db:seed`):`root@example.com` / `Root@123456`。 +- 所有接口均返回 JSON;失败时返回 `{ "message": "错误信息" }`。 +- 跨域:默认允许 `http://localhost:5173`,可通过环境变量 `CORS_ALLOWED_ORIGINS`(逗号分隔多个域名)调整。 + +## 健康检查 +- `GET /health` + 响应:`{ "status": "ok" }` + +## 登录 +- `POST /login` +- 请求体: +```json +{ "email": "user@example.com", "password": "Password123" } +``` +- 响应 200: +```json +{ + "token": "jwt-token", + "token_type": "bearer", + "expires_in": 3600, + "user": { "id": 1, "name": "root", "email": "root@example.com", "is_active": true } +} +``` +- 401:凭证无效;403:用户已停用。 + +## 当前用户 +- `GET /me`(需要 JWT) +- 响应 200:当前登录用户信息。 + +## 用户管理(需 JWT) +字段约束:`name` 必填字符串(<=255)、`email` 邮箱唯一、`password` 最少 8 字符。 + +### 用户列表 +- `GET /users` +- 查询参数:`page`(默认 1)、`per_page`(默认 15,最大 100) +- 响应 200:分页列表,`data` 为用户数组,包含 `id/name/email/is_active/created_at/updated_at`。 + +### 创建用户 +- `POST /users` +- 请求体: +```json +{ "name": "Alice", "email": "alice@example.com", "password": "Password123" } +``` +- 响应 201:新建用户(含 `is_active: true`)。 + +### 更新用户 +- `PUT /users/{id}` +- 请求体(任意字段可选): +```json +{ "name": "New Name", "email": "new@example.com", "password": "NewPass123" } +``` +- 响应 200:更新后的用户。 + +### 停用用户 +- `POST /users/{id}/deactivate` +- 响应 200:`is_active` 为 `false`。 + +### 启用用户 +- `POST /users/{id}/activate` +- 响应 200:`is_active` 为 `true`。 + +## 示例(cURL) +```bash +# 登录 +curl -X POST http://localhost:8000/api/login \ + -H "Content-Type: application/json" \ + -d '{"email":"root@example.com","password":"Root@123456"}' + +# 创建用户(替换 TOKEN) +curl -X POST http://localhost:8000/api/users \ + -H "Authorization: Bearer TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Alice","email":"alice@example.com","password":"Password123"}' + +# 获取用户列表(替换 TOKEN) +curl -X GET http://localhost:8000/api/users \ + -H "Authorization: Bearer TOKEN" +``` diff --git a/docs/user/user-openapi.yaml b/docs/user/user-openapi.yaml new file mode 100644 index 0000000..4ebac50 --- /dev/null +++ b/docs/user/user-openapi.yaml @@ -0,0 +1,365 @@ +openapi: 3.0.3 +info: + title: ars-backend API (JWT) + version: 1.0.0 + description: | + ars-backend 无状态 API,认证方式为 JWT Bearer。自然语言:中文。 +servers: + - url: http://localhost:8000/api + description: 本地开发(FrankenPHP Octane,Docker) +tags: + - name: System + description: 系统与健康检查 + - name: Auth + description: 认证相关接口 + - name: Users + description: 用户管理接口(需 JWT) +paths: + /health: + get: + tags: [System] + summary: 健康检查 + responses: + "200": + description: 服务可用 + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + /login: + post: + tags: [Auth] + summary: 用户登录 + description: 使用邮箱和密码换取 JWT,停用用户返回 403。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + "200": + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + "401": + description: 凭证无效 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "403": + description: 用户已停用 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /me: + get: + tags: [Auth] + summary: 获取当前用户 + security: + - bearerAuth: [] + responses: + "200": + description: 当前登录用户 + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: 未授权或 token 失效 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /users: + get: + tags: [Users] + summary: 用户列表 + security: + - bearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + description: 页码(默认 1) + - in: query + name: per_page + schema: + type: integer + default: 15 + maximum: 100 + description: 每页数量(1-100,默认 15) + responses: + "200": + description: 分页用户列表 + content: + application/json: + schema: + $ref: '#/components/schemas/UserPagination' + "401": + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: [Users] + summary: 创建用户 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + "201": + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "422": + description: 参数校验失败 + /users/{id}: + put: + tags: [Users] + summary: 更新用户 + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + "200": + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "422": + description: 参数校验失败 + /users/{id}/deactivate: + post: + tags: [Users] + summary: 停用用户 + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: 已停用 + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /users/{id}/activate: + post: + tags: [Users] + summary: 启用用户 + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: 已启用 + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + User: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: root + email: + type: string + format: email + example: root@example.com + is_active: + type: boolean + example: true + created_at: + type: string + format: date-time + example: 2025-12-14T05:37:47.000000Z + updated_at: + type: string + format: date-time + example: 2025-12-14T05:37:47.000000Z + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + example: root@example.com + password: + type: string + format: password + example: Root@123456 + CreateUserRequest: + type: object + required: [name, email, password] + properties: + name: + type: string + example: Alice + email: + type: string + format: email + example: alice@example.com + password: + type: string + format: password + example: Password123 + UpdateUserRequest: + type: object + properties: + name: + type: string + example: Alice Updated + email: + type: string + format: email + example: alice.updated@example.com + password: + type: string + format: password + example: NewPassword123 + AuthResponse: + type: object + properties: + token: + type: string + example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + token_type: + type: string + example: bearer + expires_in: + type: integer + example: 3600 + user: + $ref: '#/components/schemas/User' + Error: + type: object + properties: + message: + type: string + example: 凭证无效 + UserPagination: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' + PaginationLinks: + type: object + properties: + first: + type: string + example: http://localhost:8000/api/users?page=1 + last: + type: string + example: http://localhost:8000/api/users?page=1 + prev: + type: string + nullable: true + next: + type: string + nullable: true + PaginationMeta: + type: object + properties: + current_page: + type: integer + example: 1 + from: + type: integer + example: 1 + last_page: + type: integer + example: 1 + path: + type: string + example: http://localhost:8000/api/users + per_page: + type: integer + example: 15 + to: + type: integer + example: 3 + total: + type: integer + example: 3 diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..684c8ac --- /dev/null +++ b/routes/api.php @@ -0,0 +1,27 @@ + ['status' => 'ok']); + +Route::post('/login', [AuthController::class, 'login']); + +Route::middleware('auth.jwt')->group(function () { + Route::get('/me', function (Request $request) { + return $request->user(); + }); + + Route::get('/users', [UserController::class, 'index']); + Route::post('/users', [UserController::class, 'store']); + Route::put('/users/{user}', [UserController::class, 'update']); + Route::post('/users/{user}/deactivate', [UserController::class, 'deactivate']); + Route::post('/users/{user}/activate', [UserController::class, 'activate']); + + Route::post('/sessions', [ChatSessionController::class, 'store']); + Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']); + Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']); +}); diff --git a/routes/web.php b/routes/web.php index 86a06c5..4033b88 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,4 @@ use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -}); +Route::get('/', fn () => response()->json(['message' => 'ARS API', 'docs' => '/api'])); diff --git a/tests/Feature/ChatSessionTest.php b/tests/Feature/ChatSessionTest.php new file mode 100644 index 0000000..967da85 --- /dev/null +++ b/tests/Feature/ChatSessionTest.php @@ -0,0 +1,108 @@ + 'Bearer '.JWTAuth::fromUser($user)]; + } + + public function test_append_messages_sequential_seq_and_last_seq(): void + { + $service = app(ChatService::class); + $session = $service->createSession('Test Session'); + + $messages = collect(range(1, 20))->map(function ($i) use ($service, $session) { + return $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'msg '.$i, + ]); + }); + + $seqs = $messages->pluck('seq')->all(); + $this->assertEquals(range(1, 20), $seqs); + + $session->refresh(); + $this->assertEquals(20, $session->last_seq); + } + + public function test_dedupe_returns_existing_message(): void + { + $service = app(ChatService::class); + $session = $service->createSession('Test Session'); + + $first = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'hello', + 'dedupe_key' => 'k1', + ]); + + $second = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'hello again', + 'dedupe_key' => 'k1', + ]); + + $this->assertEquals($first->message_id, $second->message_id); + $this->assertEquals($first->seq, $second->seq); + $this->assertCount(1, $session->messages()->get()); + } + + public function test_closed_session_blocks_append_except_whitelisted(): void + { + $service = app(ChatService::class); + $session = $service->createSession('Test Session'); + $session->update(['status' => ChatSessionStatus::CLOSED]); + + $this->expectExceptionMessage('Session is closed'); + $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'blocked', + ]); + } + + public function test_http_append_and_list_messages(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + + $createSession = $this->withHeaders($headers)->postJson('/api/sessions', [ + 'session_name' => 'API Session', + ])->assertCreated(); + + $sessionId = $createSession->json('session_id'); + + $append = $this->withHeaders($headers)->postJson("/api/sessions/{$sessionId}/messages", [ + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'hello api', + ])->assertCreated(); + + $this->assertEquals(1, $append->json('data.seq')); + + $list = $this->withHeaders($headers)->getJson("/api/sessions/{$sessionId}/messages?after_seq=0&limit=10") + ->assertOk(); + + $this->assertEquals(1, $list->json('data.0.seq')); + $this->assertEquals('hello api', $list->json('data.0.content')); + } +} diff --git a/tests/Feature/CorsTest.php b/tests/Feature/CorsTest.php new file mode 100644 index 0000000..8d72b40 --- /dev/null +++ b/tests/Feature/CorsTest.php @@ -0,0 +1,21 @@ +withHeaders(['Origin' => $origin])->get('/api/health'); + + $response->assertOk(); + $this->assertEquals($origin, $response->headers->get('Access-Control-Allow-Origin')); + } +} diff --git a/tests/Feature/UserManagementTest.php b/tests/Feature/UserManagementTest.php new file mode 100644 index 0000000..b3044cb --- /dev/null +++ b/tests/Feature/UserManagementTest.php @@ -0,0 +1,161 @@ + 'Bearer '.JWTAuth::fromUser($user)]; + } + + public function test_can_create_user(): void + { + $admin = User::factory()->create(); + + $response = $this->withHeaders($this->authHeader($admin))->postJson('/api/users', [ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'Password123', + ]); + + $response->assertCreated()->assertJsonFragment([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'is_active' => true, + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'alice@example.com', + 'is_active' => true, + ]); + + $user = User::whereEmail('alice@example.com')->first(); + $this->assertNotNull($user); + $this->assertTrue(Hash::check('Password123', $user->password)); + } + + public function test_can_list_users(): void + { + $admin = User::factory()->create(['name' => 'Admin']); + $activeUser = User::factory()->create(['name' => 'Active User', 'email' => 'active@example.com']); + $inactiveUser = User::factory()->create(['name' => 'Inactive User', 'email' => 'inactive@example.com', 'is_active' => false]); + + $response = $this->withHeaders($this->authHeader($admin))->getJson('/api/users'); + + $response->assertOk()->assertJsonStructure([ + 'data' => [ + ['id', 'name', 'email', 'is_active', 'created_at', 'updated_at'], + ], + 'links', + 'meta', + ]); + + $response->assertJsonCount(3, 'data'); + $response->assertJsonFragment(['email' => $activeUser->email, 'is_active' => true]); + $response->assertJsonFragment(['email' => $inactiveUser->email, 'is_active' => false]); + } + + public function test_can_update_user(): void + { + $admin = User::factory()->create(); + $user = User::factory()->create(); + + $response = $this->withHeaders($this->authHeader($admin))->putJson("/api/users/{$user->id}", [ + 'name' => 'Updated User', + 'email' => 'updated@example.com', + 'password' => 'NewPassword123', + ]); + + $response->assertOk()->assertJsonFragment([ + 'id' => $user->id, + 'name' => 'Updated User', + 'email' => 'updated@example.com', + 'is_active' => true, + ]); + + $user->refresh(); + $this->assertEquals('Updated User', $user->name); + $this->assertEquals('updated@example.com', $user->email); + $this->assertTrue(Hash::check('NewPassword123', $user->password)); + } + + public function test_can_deactivate_and_activate_user(): void + { + $admin = User::factory()->create(); + $user = User::factory()->create(); + + $deactivate = $this->withHeaders($this->authHeader($admin))->postJson("/api/users/{$user->id}/deactivate"); + $deactivate->assertOk()->assertJsonFragment(['is_active' => false]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'is_active' => false, + ]); + + $activate = $this->withHeaders($this->authHeader($admin))->postJson("/api/users/{$user->id}/activate"); + $activate->assertOk()->assertJsonFragment(['is_active' => true]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'is_active' => true, + ]); + } + + public function test_user_can_login_and_receive_jwt(): void + { + $password = 'Password123'; + $user = User::factory()->create([ + 'password' => Hash::make($password), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => $user->email, + 'password' => $password, + ]); + + $response->assertOk()->assertJsonStructure([ + 'token', + 'token_type', + 'expires_in', + 'user' => ['id', 'name', 'email', 'is_active'], + ]); + } + + public function test_inactive_user_cannot_login(): void + { + $user = User::factory()->create([ + 'password' => Hash::make('Password123'), + 'is_active' => false, + ]); + + $response = $this->postJson('/api/login', [ + 'email' => $user->email, + 'password' => 'Password123', + ]); + + $response->assertForbidden()->assertJson([ + 'message' => '用户已停用', + ]); + } + + public function test_database_seeder_creates_root_user(): void + { + $this->seed(); + + $root = User::whereEmail('root@example.com')->first(); + + $this->assertNotNull($root); + $this->assertEquals('root', $root->name); + $this->assertTrue($root->is_active); + $this->assertTrue(Hash::check('Root@123456', $root->password)); + } +}