diff --git a/README.md b/README.md index a116730..c206947 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Notes: - 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)。 +- ChatSession 接口文档:`docs/ChatSession/chat-session-api.md`;OpenAPI:`docs/ChatSession/chat-session-openapi.yaml`。 - CORS:通过全局中间件开启,允许域名由环境变量 `CORS_ALLOWED_ORIGINS` 配置(默认 `http://localhost:5173`,多域名用逗号分隔)。 - 项目沟通与自然语言默认使用中文。 diff --git a/app/Http/Controllers/ChatSessionController.php b/app/Http/Controllers/ChatSessionController.php index 21f429e..2f6dff5 100644 --- a/app/Http/Controllers/ChatSessionController.php +++ b/app/Http/Controllers/ChatSessionController.php @@ -5,8 +5,9 @@ namespace App\Http\Controllers; use App\Exceptions\ChatSessionStatusException; use App\Http\Requests\AppendMessageRequest; use App\Http\Requests\CreateSessionRequest; +use App\Http\Requests\UpdateSessionRequest; +use App\Http\Resources\ChatSessionResource; use App\Http\Resources\MessageResource; -use App\Models\Message; use App\Services\ChatService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,13 +18,26 @@ class ChatSessionController extends Controller { } + /** + * 存储新的会话。 + * + * @param CreateSessionRequest $request 包含会话信息的请求实例。 + * @return JsonResponse 返回包含新创建会话信息的 JSON 响应,状态码为 201。 + */ public function store(CreateSessionRequest $request): JsonResponse { $session = $this->service->createSession($request->input('session_name')); - return response()->json($session, 201); + return (new ChatSessionResource($session))->response()->setStatusCode(201); } + /** + * 追加一条消息。 + * + * @param string $sessionId 会话 ID。 + * @param AppendMessageRequest $request 追加消息的请求实例。 + * @return JsonResponse 添加消息的响应,包含添加的消息信息。 + */ public function append(string $sessionId, AppendMessageRequest $request): JsonResponse { try { @@ -38,6 +52,13 @@ class ChatSessionController extends Controller return (new MessageResource($message))->response()->setStatusCode(201); } + /** + * 获取指定会话的消息列表。 + * + * @param Request $request 包含查询参数的请求实例,其中包括 after_seq 和 limit。 + * @param string $sessionId 会话的唯一标识符。 + * @return JsonResponse 返回包含消息列表的 JSON 响应。 + */ public function listMessages(Request $request, string $sessionId): JsonResponse { $afterSeq = (int) $request->query('after_seq', 0); @@ -48,4 +69,44 @@ class ChatSessionController extends Controller return MessageResource::collection($messages)->response(); } + + /** + * 获取会话列表。 + * + * @param Request $request 获取会话列表的请求实例。 + * @return JsonResponse 获取的会话列表的 JSON 响应。 + */ + public function index(Request $request): JsonResponse + { + $page = (int) $request->query('page', 1); + $perPage = (int) $request->query('per_page', 15); + $perPage = $perPage > 0 && $perPage <= 100 ? $perPage : 15; + + $filter = [ + 'status' => $request->query('status'), + 'q' => $request->query('q'), + ]; + + $paginator = $this->service->listSessions($filter, $page, $perPage); + + return ChatSessionResource::collection($paginator)->response(); + } + + /** + * 更新会话。 + * + * @param string $sessionId 会话的唯一标识符。 + * @param UpdateSessionRequest $request 更新会话的请求实例。 + * @return JsonResponse 更新后的会话的 JSON 响应。 + */ + public function update(string $sessionId, UpdateSessionRequest $request): JsonResponse + { + try { + $session = $this->service->updateSession($sessionId, $request->validated()); + } catch (ChatSessionStatusException $e) { + return response()->json(['message' => $e->getMessage()], 403); + } + + return (new ChatSessionResource($session))->response(); + } } diff --git a/app/Http/Requests/UpdateSessionRequest.php b/app/Http/Requests/UpdateSessionRequest.php new file mode 100644 index 0000000..0f70c26 --- /dev/null +++ b/app/Http/Requests/UpdateSessionRequest.php @@ -0,0 +1,42 @@ + + */ + public function rules(): array + { + return [ + 'session_name' => ['sometimes', 'string', 'min:1', 'max:255'], + 'status' => ['sometimes', 'string', Rule::in(ChatSessionStatus::all())], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('session_name') && is_string($this->input('session_name'))) { + $this->merge(['session_name' => trim($this->input('session_name'))]); + } + } + + public function withValidator($validator) + { + $validator->after(function ($v) { + if (! $this->has('session_name') && ! $this->has('status')) { + $v->errors()->add('payload', '至少提供一个可更新字段'); + } + }); + } +} diff --git a/app/Http/Resources/ChatSessionResource.php b/app/Http/Resources/ChatSessionResource.php new file mode 100644 index 0000000..06c43a2 --- /dev/null +++ b/app/Http/Resources/ChatSessionResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + $preview = $this->last_message_content ?? ''; + + return [ + 'session_id' => $this->session_id, + 'session_name' => $this->session_name, + 'status' => $this->status, + 'last_seq' => $this->last_seq, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'last_message_at' => $this->last_message_at, + 'last_message_preview' => $preview ? Str::limit($preview, 120) : '', + 'last_message_role' => $this->last_message_role, + 'last_message_type' => $this->last_message_type, + ]; + } +} diff --git a/app/Models/ChatSession.php b/app/Models/ChatSession.php index 7d9cae5..5665de6 100644 --- a/app/Models/ChatSession.php +++ b/app/Models/ChatSession.php @@ -22,6 +22,7 @@ class ChatSession extends Model 'session_name', 'status', 'last_seq', + 'last_message_id', ]; protected function casts(): array diff --git a/app/Services/ChatService.php b/app/Services/ChatService.php index f0dbf7d..1148e9c 100644 --- a/app/Services/ChatService.php +++ b/app/Services/ChatService.php @@ -6,6 +6,8 @@ use App\Enums\ChatSessionStatus; use App\Exceptions\ChatSessionStatusException; use App\Models\ChatSession; use App\Models\Message; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -13,6 +15,12 @@ use Illuminate\Support\Str; class ChatService { + + /** + * 创建一个新聊天会话。 + * @param string|null $name + * @return ChatSession + */ public function createSession(string $name = null): ChatSession { return ChatSession::create([ @@ -23,13 +31,31 @@ class ChatService ]); } + /** + * 根据会话ID获取聊天会话对象 + * + * @param string $sessionId 会话唯一标识符 + * @return ChatSession 返回对应的聊天会话对象 + * @throws ModelNotFoundException 当找不到指定会话时抛出异常 + */ public function getSession(string $sessionId): ChatSession { return ChatSession::query()->whereKey($sessionId)->firstOrFail(); } + /** - * @param array $dto + * 将消息追加到指定的聊天会话中。 + * + * @param array $dto 包含消息详细信息的数据传输对象,包括以下键: + * - session_id: 聊天会话的唯一标识符 + * - role: 消息角色(如发件人或收件人) + * - type: 消息类型 + * - content: 消息内容(可选) + * - payload: 附加信息(可选) + * - reply_to: 被回复的消息 ID(可选) + * - dedupe_key: 消息去重键(可选) + * @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。 */ public function appendMessage(array $dto): Message { @@ -88,6 +114,7 @@ class ChatService $session->update([ 'last_seq' => $newSeq, + 'last_message_id' => $message->message_id, 'updated_at' => now(), ]); @@ -95,6 +122,14 @@ class ChatService }); } + /** + * 列出指定会话中指定序号之后的消息。 + * + * @param string $sessionId 会话唯一标识符 + * @param int $afterSeq 指定序号之后的消息将被列出 + * @param int $limit 最多返回的消息数量,默认为 50 + * @return Collection 返回指定序号之后的消息列表 + */ public function listMessagesBySeq(string $sessionId, int $afterSeq, int $limit = 50): Collection { $this->getSession($sessionId); // ensure exists @@ -107,6 +142,89 @@ class ChatService ->get(); } + /** + * 获取会话列表 + * + * @param array $filter 过滤条件数组,支持status状态过滤和q关键字搜索 + * @param int $page 页码 + * @param int $perPage 每页数量 + * @return LengthAwarePaginator 分页结果 + */ + public function listSessions(array $filter, int $page, int $perPage): LengthAwarePaginator + { + // 构建基础查询 + $query = $this->baseSessionQuery(); + + // 根据状态过滤会话 + if (! empty($filter['status'])) { + $query->where('chat_sessions.status', $filter['status']); + } + + // 根据会话名称进行模糊搜索 + if (! empty($filter['q'])) { + $q = $filter['q']; + $query->where('chat_sessions.session_name', 'ilike', '%'.$q.'%'); + } + + // 按更新时间倒序排列 + $query->orderByDesc('chat_sessions.updated_at'); + + // 执行分页查询 + return $query->paginate($perPage, ['*'], 'page', $page); + } + + + /** + * 更新聊天会话信息 + * + * @param string $sessionId 会话ID + * @param array $patch 需要更新的字段数据 + * @return ChatSession 更新后的聊天会话对象 + * @throws ChatSessionStatusException 当尝试重新打开已关闭的会话时抛出异常 + */ + public function updateSession(string $sessionId, array $patch): ChatSession + { + // 查找包含最后一条消息的会话 + $session = $this->findSessionWithLastMessage($sessionId); + + // 检查会话状态变更合法性:已关闭的会话不能重新打开 + if (isset($patch['status']) && $session->status === ChatSessionStatus::CLOSED && $patch['status'] !== ChatSessionStatus::CLOSED) { + throw new ChatSessionStatusException('Closed session cannot be reopened'); + } + + // 填充更新数据并保存 + $session->fill($patch); + $session->updated_at = now(); + $session->save(); + + // 返回更新后的会话信息 + return $this->findSessionWithLastMessage($sessionId); + } + + + private function baseSessionQuery() + { + return ChatSession::query() + ->leftJoin('messages as lm', 'lm.message_id', '=', 'chat_sessions.last_message_id') + ->select('chat_sessions.*') + ->addSelect([ + 'lm.created_at as last_message_at', + 'lm.content as last_message_content', + 'lm.role as last_message_role', + 'lm.type as last_message_type', + ]); + } + + private function findSessionWithLastMessage(string $sessionId): ChatSession + { + /** @var ChatSession $session */ + $session = $this->baseSessionQuery() + ->where('chat_sessions.session_id', $sessionId) + ->firstOrFail(); + + return $session; + } + private function ensureCanAppend(ChatSession $session, string $role, string $type): void { if ($session->status === ChatSessionStatus::CLOSED) { diff --git a/database/migrations/2025_02_14_000004_add_last_message_to_chat_sessions.php b/database/migrations/2025_02_14_000004_add_last_message_to_chat_sessions.php new file mode 100644 index 0000000..dd46d97 --- /dev/null +++ b/database/migrations/2025_02_14_000004_add_last_message_to_chat_sessions.php @@ -0,0 +1,28 @@ +uuid('last_message_id')->nullable()->after('last_seq'); + $table->index('updated_at'); + $table->index('status'); + $table->index('last_message_id'); + }); + } + + public function down(): void + { + Schema::table('chat_sessions', function (Blueprint $table) { + $table->dropIndex(['updated_at']); + $table->dropIndex(['status']); + $table->dropIndex(['last_message_id']); + $table->dropColumn('last_message_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c04e78c..0a562e9 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -25,5 +25,15 @@ class DatabaseSeeder extends Seeder 'email_verified_at' => now(), ], ); + + User::updateOrCreate( + ['email' => 'guxinpei@qq.com'], + [ + 'name' => 'roog', + 'password' => Hash::make('w2021976'), + 'is_active' => true, + 'email_verified_at' => now(), + ], + ); } } diff --git a/docs/ChatSession/chat-session-api.md b/docs/ChatSession/chat-session-api.md new file mode 100644 index 0000000..08b1866 --- /dev/null +++ b/docs/ChatSession/chat-session-api.md @@ -0,0 +1,96 @@ +# ChatSession & Message API(MVP-1) + +基地址:`http://localhost:8000/api`(FrankenPHP 容器 8000 端口) +认证方式:JWT,`Authorization: Bearer {token}` +自然语言:中文 + +## 变更记录 +- 2025-02-14:新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。 +- 2025-02-14:MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。 + +## 领域模型 +- `ChatSession`:`session_id`(UUID)、`session_name`、`status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq` +- `Message`:`message_id`(UUID)、`session_id`、`role`(`USER`/`AGENT`/`TOOL`/`SYSTEM`)、`type`(字符串)、`content`、`payload`(json)、`seq`(会话内递增)、`reply_to`(UUID)、`dedupe_key` +- 幂等:`UNIQUE (session_id, dedupe_key)`;同一 dedupe_key 返回已有消息。 +- 状态门禁:`CLOSED` 禁止追加,例外 `role=SYSTEM && type in [run.status, error]`;`LOCKED` 禁止 `role=USER && type=user.prompt`。 +- 会话缓存:`chat_sessions.last_message_id` 记录最后一条消息;`appendMessage` 事务内同步更新 `last_seq`、`last_message_id`、`updated_at`。 + +## 接口 +### 创建会话 +- `POST /sessions` +- 请求体字段: + - `session_name` (string, 可选,<=255):会话名称。 +- 响应 201 字段: + - `session_id` (uuid) + - `session_name` (string|null) + - `status` (`OPEN|LOCKED|CLOSED`) + - `last_seq` (int) + - `last_message_id` (uuid|null) + - `created_at` / `updated_at` + +### 追加消息 +- `POST /sessions/{session_id}/messages` +- 请求体字段: + - `role` (required, `USER|AGENT|TOOL|SYSTEM`) + - `type` (required, string, <=64),如 `user.prompt`/`agent.message` 等。 + - `content` (string|null) + - `payload` (object|null) 作为 jsonb 存储。 + - `reply_to` (uuid|null) + - `dedupe_key` (string|null, <=128) 幂等键。 +- 响应 201 字段: + - `message_id` (uuid) + - `session_id` (uuid) + - `seq` (int,会话内递增) + - `role` / `type` / `content` / `payload` / `reply_to` / `dedupe_key` + - `created_at` +- 403:违反状态门禁(CLOSED 禁止,LOCKED 禁止 user.prompt)。 +- 幂等:同 session + dedupe_key 返回已有消息(同 `message_id/seq`)。 + +### 按序增量查询 +- `GET /sessions/{session_id}/messages?after_seq=0&limit=50` +- 查询参数: + - `after_seq` (int, 默认 0):仅返回大于该 seq 的消息。 + - `limit` (int, 默认 50,<=200)。 +- 响应 200:`data` 数组,元素字段同“追加消息”响应。 + +### 会话列表 +- `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword` +- 查询参数: + - `page` (int, 默认 1) + - `per_page` (int, 默认 15,<=100) + - `status` (`OPEN|LOCKED|CLOSED`,可选) + - `q` (string,可选,对 `session_name` ILIKE 模糊匹配) +- 响应 200:分页结构(`data/links/meta`),`data` 每项字段: + - `session_id, session_name, status, last_seq, created_at, updated_at` + - `last_message_id` + - `last_message_at` + - `last_message_preview`(content 截断 120,content 为空则空字符串) + - `last_message_role, last_message_type` + - 排序:`updated_at` DESC + +### 会话更新 +- `PATCH /sessions/{session_id}` +- 请求体(至少提供一项,否则 422): + - `session_name` (string, 1..255,可选,自动 trim) + - `status` (`OPEN|LOCKED|CLOSED`,可选) +- 规则: + - `CLOSED` 不可改回 `OPEN`(返回 403)。 + - 任意更新都会刷新 `updated_at`。 +- 响应 200 字段:同会话列表项字段。 + +## cURL 示例 +```bash +# 创建会话 +SESSION_ID=$(curl -s -X POST http://localhost:8000/api/sessions \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"session_name":"Demo"}' | jq -r '.data.session_id') + +# 追加消息(支持 dedupe_key 幂等) +curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/messages \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"role":"USER","type":"user.prompt","content":"hello","dedupe_key":"k1"}' + +# 增量查询 +curl -s "http://localhost:8000/api/sessions/$SESSION_ID/messages?after_seq=0&limit=50" \ + -H "Authorization: Bearer $TOKEN" +``` diff --git a/docs/ChatSession/chat-session-openapi.yaml b/docs/ChatSession/chat-session-openapi.yaml new file mode 100644 index 0000000..a7afe13 --- /dev/null +++ b/docs/ChatSession/chat-session-openapi.yaml @@ -0,0 +1,344 @@ +openapi: 3.0.3 +info: + title: ChatSession & Message API + version: 1.0.0 + description: | + ChatSession & Message MVP-1,支持会话创建、消息追加、增量查询。自然语言:中文。 +servers: + - url: http://localhost:8000/api + description: 本地开发(FrankenPHP / Docker) +tags: + - name: ChatSession + description: 会话管理与消息 +paths: + /sessions: + post: + tags: [ChatSession] + summary: 创建会话 + security: + - bearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSessionRequest' + responses: + "201": + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSession' + "401": + description: 未授权 + get: + tags: [ChatSession] + summary: 会话列表 + security: + - bearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + - in: query + name: per_page + schema: + type: integer + default: 15 + maximum: 100 + - in: query + name: status + schema: + type: string + enum: [OPEN, LOCKED, CLOSED] + - in: query + name: q + schema: + type: string + description: 模糊匹配 session_name + responses: + "200": + description: 分页会话列表 + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ChatSession' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' + "401": + description: 未授权 + /sessions/{session_id}/messages: + post: + tags: [ChatSession] + summary: 追加消息(含幂等与状态门禁) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppendMessageRequest' + responses: + "201": + description: 追加成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResource' + "403": + description: 状态门禁禁止 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "401": + description: 未授权 + get: + tags: [ChatSession] + summary: 按 seq 增量查询消息 + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + - in: query + name: after_seq + schema: + type: integer + default: 0 + description: 仅返回 seq 大于该值的消息 + - in: query + name: limit + schema: + type: integer + default: 50 + maximum: 200 + description: 返回数量上限 + responses: + "200": + description: 按 seq 升序的消息列表 + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/MessageResource' + "401": + description: 未授权 + /sessions/{session_id}: + patch: + tags: [ChatSession] + summary: 更新会话(重命名/状态) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSessionRequest' + responses: + "200": + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSession' + "403": + description: 状态门禁禁止 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "422": + description: 校验失败 + "401": + description: 未授权 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ChatSession: + type: object + properties: + session_id: + type: string + format: uuid + session_name: + type: string + nullable: true + status: + type: string + enum: [OPEN, LOCKED, CLOSED] + last_seq: + type: integer + last_message_id: + type: string + format: uuid + nullable: true + last_message_at: + type: string + format: date-time + nullable: true + last_message_preview: + type: string + last_message_role: + type: string + nullable: true + last_message_type: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + CreateSessionRequest: + type: object + properties: + session_name: + type: string + maxLength: 255 + UpdateSessionRequest: + type: object + properties: + session_name: + type: string + minLength: 1 + maxLength: 255 + status: + type: string + enum: [OPEN, LOCKED, CLOSED] + AppendMessageRequest: + type: object + required: [role, type] + properties: + role: + type: string + enum: [USER, AGENT, TOOL, SYSTEM] + type: + type: string + maxLength: 64 + example: user.prompt + content: + type: string + nullable: true + payload: + type: object + nullable: true + reply_to: + type: string + format: uuid + nullable: true + dedupe_key: + type: string + maxLength: 128 + nullable: true + MessageResource: + type: object + properties: + message_id: + type: string + format: uuid + session_id: + type: string + format: uuid + seq: + type: integer + role: + type: string + enum: [USER, AGENT, TOOL, SYSTEM] + type: + type: string + content: + type: string + nullable: true + payload: + type: object + nullable: true + reply_to: + type: string + format: uuid + nullable: true + dedupe_key: + type: string + nullable: true + created_at: + type: string + format: date-time + Error: + type: object + properties: + message: + type: string + example: Session is closed + PaginationLinks: + type: object + properties: + first: + type: string + example: http://localhost:8000/api/sessions?page=1 + last: + type: string + example: http://localhost:8000/api/sessions?page=1 + prev: + type: string + nullable: true + next: + type: string + nullable: true + PaginationMeta: + type: object + properties: + current_page: + type: integer + from: + type: integer + nullable: true + last_page: + type: integer + path: + type: string + per_page: + type: integer + to: + type: integer + nullable: true + total: + type: integer diff --git a/docs/user/user-api.md b/docs/User/user-api.md similarity index 100% rename from docs/user/user-api.md rename to docs/User/user-api.md diff --git a/docs/user/user-openapi.yaml b/docs/User/user-openapi.yaml similarity index 100% rename from docs/user/user-openapi.yaml rename to docs/User/user-openapi.yaml diff --git a/routes/api.php b/routes/api.php index 684c8ac..c5e0075 100644 --- a/routes/api.php +++ b/routes/api.php @@ -21,7 +21,9 @@ Route::middleware('auth.jwt')->group(function () { Route::post('/users/{user}/deactivate', [UserController::class, 'deactivate']); Route::post('/users/{user}/activate', [UserController::class, 'activate']); + Route::get('/sessions', [ChatSessionController::class, 'index']); Route::post('/sessions', [ChatSessionController::class, 'store']); Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']); Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']); + Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']); }); diff --git a/tests/Feature/ChatSessionTest.php b/tests/Feature/ChatSessionTest.php index 967da85..43d055e 100644 --- a/tests/Feature/ChatSessionTest.php +++ b/tests/Feature/ChatSessionTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Enums\ChatSessionStatus; use App\Models\User; use App\Services\ChatService; +use Illuminate\Support\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; use Tests\TestCase; @@ -89,7 +90,7 @@ class ChatSessionTest extends TestCase 'session_name' => 'API Session', ])->assertCreated(); - $sessionId = $createSession->json('session_id'); + $sessionId = $createSession->json('data.session_id'); $append = $this->withHeaders($headers)->postJson("/api/sessions/{$sessionId}/messages", [ 'role' => 'USER', @@ -105,4 +106,116 @@ class ChatSessionTest extends TestCase $this->assertEquals(1, $list->json('data.0.seq')); $this->assertEquals('hello api', $list->json('data.0.content')); } + + public function test_session_list_sorted_and_last_message_preview(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + + Carbon::setTestNow('2025-01-01 00:00:00'); + $s1 = $service->createSession('First'); + $service->appendMessage([ + 'session_id' => $s1->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'hello first', + ]); + + Carbon::setTestNow('2025-01-01 00:00:10'); + $s2 = $service->createSession('Second'); + $service->appendMessage([ + 'session_id' => $s2->session_id, + 'role' => 'USER', + 'type' => 'user.prompt', + 'content' => 'hello second', + ]); + + Carbon::setTestNow(); + + $resp = $this->withHeaders($headers)->getJson('/api/sessions?per_page=10') + ->assertOk(); + + $this->assertEquals($s2->session_id, $resp->json('data.0.session_id')); + $this->assertEquals('hello second', $resp->json('data.0.last_message_preview')); + $this->assertEquals('hello first', $resp->json('data.1.last_message_preview')); + $this->assertNotNull($resp->json('data.0.last_message_at')); + } + + public function test_session_list_filters_status_and_query(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + + $open = $service->createSession('Alpha'); + $locked = $service->createSession('Beta'); + $locked->update(['status' => ChatSessionStatus::LOCKED]); + $closed = $service->createSession('Gamma'); + $closed->update(['status' => ChatSessionStatus::CLOSED]); + + $respStatus = $this->withHeaders($headers)->getJson('/api/sessions?status=LOCKED') + ->assertOk(); + $this->assertCount(1, $respStatus->json('data')); + $this->assertEquals($locked->session_id, $respStatus->json('data.0.session_id')); + + $respQuery = $this->withHeaders($headers)->getJson('/api/sessions?q=Alpha') + ->assertOk(); + $this->assertCount(1, $respQuery->json('data')); + $this->assertEquals($open->session_id, $respQuery->json('data.0.session_id')); + } + + public function test_patch_updates_session_name(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + $session = $service->createSession('Old Name'); + + $resp = $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [ + 'session_name' => 'New Name', + ])->assertOk(); + + $this->assertEquals('New Name', $resp->json('data.session_name')); + } + + public function test_patch_updates_status_transitions(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + $session = $service->createSession('Status Session'); + + $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [ + 'status' => ChatSessionStatus::LOCKED, + ])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::LOCKED]); + + $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [ + 'status' => ChatSessionStatus::OPEN, + ])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::OPEN]); + } + + public function test_closed_session_cannot_reopen(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + $session = $service->createSession('Closed Session'); + $session->update(['status' => ChatSessionStatus::CLOSED]); + + $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [ + 'status' => ChatSessionStatus::OPEN, + ])->assertStatus(403); + } + + public function test_empty_patch_rejected(): void + { + $user = User::factory()->create(); + $headers = $this->authHeader($user); + $service = app(ChatService::class); + $session = $service->createSession('Patch Empty'); + + $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", []) + ->assertStatus(422); + } }