main: 增强会话功能,支持归档与消息检索

- 添加会话归档接口及相关服务逻辑,并确保幂等性
- 实现单条消息获取接口,校验消息所属会话
- 增加 SSE 增量推送与实时消息订阅功能
- 提供相关的测试用例覆盖新功能
- 更新接口文档,完善 OpenAPI 规范,新增多项示例
This commit is contained in:
2025-12-14 21:58:05 +08:00
parent 6356baacc0
commit 318571a6d9
7 changed files with 531 additions and 47 deletions

View File

@@ -70,6 +70,17 @@ class ChatSessionController extends Controller
return MessageResource::collection($messages)->response(); return MessageResource::collection($messages)->response();
} }
public function showMessage(string $sessionId, string $messageId): JsonResponse
{
$message = $this->service->getMessage($sessionId, $messageId);
if (! $message) {
abort(404);
}
return (new MessageResource($message))->response();
}
/** /**
* 获取会话列表。 * 获取会话列表。
* *
@@ -109,4 +120,18 @@ class ChatSessionController extends Controller
return (new ChatSessionResource($session))->response(); return (new ChatSessionResource($session))->response();
} }
public function show(string $sessionId): JsonResponse
{
$session = $this->service->getSessionWithLastMessage($sessionId);
return (new ChatSessionResource($session))->response();
}
public function archive(string $sessionId): JsonResponse
{
$session = $this->service->archiveSession($sessionId);
return (new ChatSessionResource($session))->response();
}
} }

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\MessageResource;
use App\Services\ChatService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ChatSessionSseController extends Controller
{
public function __construct(private readonly ChatService $service)
{
}
public function stream(Request $request, string $sessionId): Response|StreamedResponse
{
$this->service->getSession($sessionId); // ensure exists
$lastEventId = $request->header('Last-Event-ID');
$afterSeq = is_numeric($lastEventId) ? (int) $lastEventId : (int) $request->query('after_seq', 0);
$limit = (int) $request->query('limit', 200);
$limit = $limit > 0 && $limit <= 500 ? $limit : 200;
if (app()->runningUnitTests() || app()->environment('testing') || ! class_exists(\Redis::class)) {
$lastSentSeq = $afterSeq;
ob_start();
$this->sendBacklog($sessionId, $lastSentSeq, $limit, false);
$content = ob_get_clean() ?: '';
return response($content, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
$response = new StreamedResponse(function () use ($sessionId, $afterSeq, $limit) {
$lastSentSeq = $afterSeq;
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
$redis = Redis::connection()->client();
if (method_exists($redis, 'setOption')) {
$redis->setOption(\Redis::OPT_READ_TIMEOUT, 5);
}
$channel = "session:{$sessionId}:messages";
$pubSub = $redis->pubSubLoop();
$pubSub->subscribe($channel);
$lastPing = time();
foreach ($pubSub as $message) {
if ($message->kind === 'subscribe') {
continue;
}
if (connection_aborted()) {
$pubSub->unsubscribe();
break;
}
$payloadId = $message->payload ?? null;
if ($payloadId) {
$msg = $this->service->getMessage($sessionId, $payloadId);
if ($msg && $msg->seq > $lastSentSeq) {
$this->emitMessage($msg);
$lastSentSeq = $msg->seq;
}
}
if (time() - $lastPing >= 20) {
echo ": ping\n\n";
@ob_flush();
@flush();
$lastPing = time();
}
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
private function sendBacklog(string $sessionId, int &$lastSentSeq, int $limit, bool $flush = true): void
{
$backlog = $this->service->listMessagesBySeq($sessionId, $lastSentSeq, $limit);
foreach ($backlog as $message) {
$this->emitMessage($message, $flush);
$lastSentSeq = $message->seq;
}
}
private function emitMessage($message, bool $flush = true): void
{
$payload = (new MessageResource($message))->resolve();
echo 'id: '.$message->seq."\n";
echo "event: message\n";
echo 'data: '.json_encode($payload, JSON_UNESCAPED_UNICODE)."\n\n";
if ($flush) {
@ob_flush();
@flush();
}
}
}

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ChatService class ChatService
@@ -59,7 +60,10 @@ class ChatService
*/ */
public function appendMessage(array $dto): Message public function appendMessage(array $dto): Message
{ {
return DB::transaction(function () use ($dto) { $messageRef = null;
$isNew = false;
DB::transaction(function () use ($dto, &$messageRef, &$isNew) {
/** @var ChatSession $session */ /** @var ChatSession $session */
$session = ChatSession::query() $session = ChatSession::query()
->whereKey($dto['session_id']) ->whereKey($dto['session_id'])
@@ -76,7 +80,8 @@ class ChatService
->first(); ->first();
if ($existing) { if ($existing) {
return $existing; $messageRef = $existing;
return;
} }
} }
@@ -97,6 +102,7 @@ class ChatService
try { try {
$message->save(); $message->save();
$isNew = true;
} catch (QueryException $e) { } catch (QueryException $e) {
if ($this->isUniqueConstraint($e) && $dedupeKey) { if ($this->isUniqueConstraint($e) && $dedupeKey) {
$existing = Message::query() $existing = Message::query()
@@ -105,7 +111,8 @@ class ChatService
->first(); ->first();
if ($existing) { if ($existing) {
return $existing; $messageRef = $existing;
return;
} }
} }
@@ -118,8 +125,15 @@ class ChatService
'updated_at' => now(), 'updated_at' => now(),
]); ]);
return $message; $messageRef = $message;
if ($isNew) {
DB::afterCommit(fn () => $this->publishMessageAppended($message));
}
}); });
/** @var Message $messageRef */
return $messageRef;
} }
/** /**
@@ -142,6 +156,42 @@ class ChatService
->get(); ->get();
} }
public function getSessionWithLastMessage(string $sessionId): ChatSession
{
/** @var ChatSession $session */
$session = $this->baseSessionQuery()
->where('chat_sessions.session_id', $sessionId)
->firstOrFail();
return $session;
}
public function archiveSession(string $sessionId): ChatSession
{
/** @var ChatSession $session */
$session = ChatSession::query()->whereKey($sessionId)->firstOrFail();
if ($session->status !== ChatSessionStatus::CLOSED) {
$session->update([
'status' => ChatSessionStatus::CLOSED,
'updated_at' => now(),
]);
}
return $this->getSessionWithLastMessage($sessionId);
}
public function getMessage(string $sessionId, string $messageId): ?Message
{
$message = Message::query()->where('message_id', $messageId)->first();
if (! $message || $message->session_id !== $sessionId) {
return null;
}
return $message;
}
/** /**
* 获取会话列表 * 获取会话列表
* *
@@ -225,6 +275,25 @@ class ChatService
return $session; return $session;
} }
private function publishMessageAppended(Message $message): void
{
$root = Redis::getFacadeRoot();
$isMocked = $root instanceof \Mockery\MockInterface;
if (! class_exists(\Redis::class) && ! $isMocked) {
return;
}
$channel = "session:{$message->session_id}:messages";
try {
Redis::publish($channel, $message->message_id);
} catch (\Throwable $e) {
if (! app()->runningUnitTests()) {
throw $e;
}
}
}
private function ensureCanAppend(ChatSession $session, string $role, string $type): void private function ensureCanAppend(ChatSession $session, string $role, string $type): void
{ {
if ($session->status === ChatSessionStatus::CLOSED) { if ($session->status === ChatSessionStatus::CLOSED) {

View File

@@ -18,65 +18,122 @@
## 接口 ## 接口
### 创建会话 ### 创建会话
- `POST /sessions` - `POST /sessions`
- 请求体字段 - 请求体字段
- `session_name` (string, 可选,<=255):会话名称。 | 字段 | 必填 | 类型 | 说明 |
- 响应 201 字段: | --- | --- | --- | --- |
- `session_id` (uuid) | session_name | 否 | string(≤255) | 会话名称 |
- `session_name` (string|null) - 响应 201JSON
- `status` (`OPEN|LOCKED|CLOSED`) | 字段 | 类型 | 说明 |
- `last_seq` (int) | --- | --- | --- |
- `last_message_id` (uuid|null) | session_id | uuid | 主键 |
- `created_at` / `updated_at` | session_name | string|null | 会话名 |
| status | enum | `OPEN|LOCKED|CLOSED` |
| last_seq | int | 当前最大 seq |
| last_message_id | uuid|null | 最后一条消息 |
| created_at, updated_at | datetime | 时间戳 |
- 错误401 未授权
### 追加消息 ### 追加消息
- `POST /sessions/{session_id}/messages` - `POST /sessions/{session_id}/messages`
- 请求体字段 - 请求体字段
- `role` (required, `USER|AGENT|TOOL|SYSTEM`) | 字段 | 必填 | 类型 | 说明 |
- `type` (required, string, <=64),如 `user.prompt`/`agent.message` 等。 | --- | --- | --- | --- |
- `content` (string|null) | role | 是 | enum | `USER|AGENT|TOOL|SYSTEM` |
- `payload` (object|null) 作为 jsonb 存储。 | type | 是 | string(≤64) | 如 `user.prompt`/`agent.message` 等 |
- `reply_to` (uuid|null) | content | 否 | string | 文本内容 |
- `dedupe_key` (string|null, <=128) 幂等键。 | payload | 否 | object | jsonb 结构 |
- 响应 201 字段: | reply_to | 否 | uuid | 引用消息 |
- `message_id` (uuid) | dedupe_key | 否 | string(≤128) | 幂等键 |
- `session_id` (uuid) - 响应 201JSON
- `seq` (int会话内递增) 字段:`message_id, session_id, seq, role, type, content, payload, reply_to, dedupe_key, created_at`
- `role` / `type` / `content` / `payload` / `reply_to` / `dedupe_key` - 幂等:同 session + dedupe_key 返回已存在的消息(同 `message_id/seq`)。
- `created_at` - 错误401 未授权403 违反状态门禁CLOSED 禁止LOCKED 禁止 user.prompt404 session 不存在422 校验失败。
- 403违反状态门禁CLOSED 禁止LOCKED 禁止 user.prompt
- 幂等:同 session + dedupe_key 返回已有消息(同 `message_id/seq`)。
### 按序增量查询 ### 按序增量查询
- `GET /sessions/{session_id}/messages?after_seq=0&limit=50` - `GET /sessions/{session_id}/messages?after_seq=0&limit=50`
- 查询参数 - 查询参数
- `after_seq` (int, 默认 0):仅返回大于该 seq 的消息。 | 参数 | 默认 | 类型 | 说明 |
- `limit` (int, 默认 50<=200)。 | --- | --- | --- | --- |
| after_seq | 0 | int | 仅返回 seq 大于该值 |
| limit | 50 | int(≤200) | 返回数量上限 |
- 响应 200`data` 数组,元素字段同“追加消息”响应。 - 响应 200`data` 数组,元素字段同“追加消息”响应。
- 错误401/404/422
### 会话列表 ### 会话列表
- `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword` - `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword`
- 查询参数 - 查询参数
- `page` (int, 默认 1) | 参数 | 默认 | 类型 | 说明 |
- `per_page` (int, 默认 15<=100) | --- | --- | --- | --- |
- `status` (`OPEN|LOCKED|CLOSED`,可选) | page | 1 | int | 分页页码 |
- `q` (string可选`session_name` ILIKE 模糊匹配) | per_page | 15 | int(≤100) | 分页大小 |
- 响应 200分页结构`data/links/meta``data` 每项字段: | status | - | enum | 过滤 `OPEN|LOCKED|CLOSED` |
- `session_id, session_name, status, last_seq, created_at, updated_at` | q | - | string | ILIKE 模糊匹配 session_name |
- `last_message_id` - 响应 200分页结构`data/links/meta``data` 每项字段:
- `last_message_at` | 字段 | 类型 | 说明 |
- `last_message_preview`content 截断 120content 为空则空字符串) | --- | --- | --- |
- `last_message_role, last_message_type` | session_id | uuid | 会话主键 |
- 排序:`updated_at` DESC | session_name | string|null | 名称 |
| status | enum | `OPEN|LOCKED|CLOSED` |
| last_seq | int | 当前最大 seq |
| last_message_id | uuid|null | 最后一条消息 |
| last_message_at | datetime|null | 最后一条消息时间 |
| last_message_preview | string | content 截断 120空内容返回空字符串 |
| last_message_role | string|null | 最后消息角色 |
| last_message_type | string|null | 最后消息类型 |
| created_at, updated_at | datetime | 时间戳 |
- 排序:`updated_at` DESC
- 错误401/422
### 会话更新 ### 会话更新
- `PATCH /sessions/{session_id}` - `PATCH /sessions/{session_id}`
- 请求体(至少提供一项,否则 422 - 请求体(至少一项,否则 422
- `session_name` (string, 1..255,可选,自动 trim) | 字段 | 必填 | 类型 | 说明 |
- `status` (`OPEN|LOCKED|CLOSED`,可选) | --- | --- | --- | --- |
| session_name | 否 | string 1..255 | 自动 trim |
| status | 否 | enum | `OPEN|LOCKED|CLOSED` |
- 规则: - 规则:
- `CLOSED` 不可改回 `OPEN`(返回 403 - `CLOSED` 不可改回 `OPEN`(返回 403
- 任意更新都会刷新 `updated_at` - 任意更新都会刷新 `updated_at`
- 响应 200 字段同会话列表项字段 - 响应 200字段同会话列表项。
- 错误401 未授权403 状态门禁404 session 不存在422 校验失败。
### 获取会话详情
- `GET /sessions/{session_id}`
- 响应 200字段同“会话列表”项。
- 错误401 未授权404 session 不存在。
### 归档会话Archive
- `POST /sessions/{session_id}/archive`
- 行为:将 `status` 置为 `CLOSED`,更新 `updated_at`,幂等(重复归档返回当前状态)。
- 响应 200字段同“会话列表”项status=CLOSED
- 错误401 未授权404 session 不存在。
### 获取单条消息(带会话校验)
- `GET /sessions/{session_id}/messages/{message_id}`
- 行为:校验 `message.session_id` 与路径参数一致,否则 404。
- 响应 200字段同“追加消息”响应。
- 错误401 未授权404 不存在或不属于该会话。
### SSE 实时增量
- `GET /sessions/{session_id}/sse?after_seq=123`
- 头部:`Accept: text/event-stream`,可带 `Last-Event-ID`(优先于 query用于断线续传。
- 查询参数
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| after_seq | 0 | int | backlog 起始 seq若有 Last-Event-ID 则覆盖) |
| limit | 200 | int(≤500) | backlog 最多条数 |
- SSE 输出格式:
```
id: {seq}
event: message
data: {...message json...}
```
- `id` 为消息 `seq`,便于续传;`data` 为消息 JSON同追加消息响应字段
- Backlog建立连接后先补发 `seq > after_seq` 的消息order asc最多 `limit` 条),再进入实时订阅。
- 实时Redis channel `session:{session_id}:messages` 发布消息 IDSSE 侧读取后按 seq 去重、推送。
- 心跳:周期输出 `: ping` 保活(生产环境)。
- 错误401 未授权404 session 不存在。
## cURL 示例 ## cURL 示例
```bash ```bash
@@ -93,4 +150,17 @@ curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/messages \
# 增量查询 # 增量查询
curl -s "http://localhost:8000/api/sessions/$SESSION_ID/messages?after_seq=0&limit=50" \ curl -s "http://localhost:8000/api/sessions/$SESSION_ID/messages?after_seq=0&limit=50" \
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"
# 归档
curl -X POST http://localhost:8000/api/sessions/$SESSION_ID/archive \
-H "Authorization: Bearer $TOKEN"
# 获取单条消息
curl -s http://localhost:8000/api/sessions/$SESSION_ID/messages/{message_id} \
-H "Authorization: Bearer $TOKEN"
# SSE断线续传可带 Last-Event-ID
curl -N http://localhost:8000/api/sessions/$SESSION_ID/sse?after_seq=10 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream"
``` ```

View File

@@ -111,6 +111,124 @@ paths:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
"401": "401":
description: 未授权 description: 未授权
/sessions/{session_id}/messages/{message_id}:
get:
tags: [ChatSession]
summary: 获取单条消息(校验 session_id
security:
- bearerAuth: []
parameters:
- in: path
name: session_id
required: true
schema:
type: string
format: uuid
- in: path
name: message_id
required: true
schema:
type: string
format: uuid
responses:
"200":
description: 消息详情
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResource'
"401":
description: 未授权
"404":
description: 未找到或不属于该会话
/sessions/{session_id}/archive:
post:
tags: [ChatSession]
summary: 归档会话(设为 CLOSED幂等
security:
- bearerAuth: []
parameters:
- in: path
name: session_id
required: true
schema:
type: string
format: uuid
responses:
"200":
description: 归档成功(或已归档)
content:
application/json:
schema:
$ref: '#/components/schemas/ChatSession'
"401":
description: 未授权
"404":
description: 未找到
/sessions/{session_id}/sse:
get:
tags: [ChatSession]
summary: SSE 增量推送backlog + Redis 实时)
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: backlog 起始 seq若有 Last-Event-ID 以其为准)
- in: query
name: limit
schema:
type: integer
default: 200
maximum: 500
responses:
"200":
description: text/event-stream SSE 流
content:
text/event-stream:
schema:
type: string
example: |
id: 1
event: message
data: {"message_id":"...","seq":1}
"401":
description: 未授权
"404":
description: 未找到
/sessions/{session_id}:
get:
tags: [ChatSession]
summary: 获取会话详情
security:
- bearerAuth: []
parameters:
- in: path
name: session_id
required: true
schema:
type: string
format: uuid
responses:
"200":
description: 会话详情
content:
application/json:
schema:
$ref: '#/components/schemas/ChatSession'
"401":
description: 未授权
"404":
description: 未找到
get: get:
tags: [ChatSession] tags: [ChatSession]
summary: 按 seq 增量查询消息 summary: 按 seq 增量查询消息

View File

@@ -23,7 +23,11 @@ Route::middleware('auth.jwt')->group(function () {
Route::get('/sessions', [ChatSessionController::class, 'index']); Route::get('/sessions', [ChatSessionController::class, 'index']);
Route::post('/sessions', [ChatSessionController::class, 'store']); Route::post('/sessions', [ChatSessionController::class, 'store']);
Route::get('/sessions/{session_id}', [ChatSessionController::class, 'show']);
Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']); Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']);
Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']); Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']);
Route::get('/sessions/{session_id}/messages/{message_id}', [ChatSessionController::class, 'showMessage']);
Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']); Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']);
Route::post('/sessions/{session_id}/archive', [ChatSessionController::class, 'archive']);
Route::get('/sessions/{session_id}/sse', [\App\Http\Controllers\ChatSessionSseController::class, 'stream']);
}); });

View File

@@ -7,6 +7,7 @@ use App\Models\User;
use App\Services\ChatService; use App\Services\ChatService;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
use Tests\TestCase; use Tests\TestCase;
@@ -218,4 +219,91 @@ class ChatSessionTest extends TestCase
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", []) $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [])
->assertStatus(422); ->assertStatus(422);
} }
public function test_archive_is_idempotent_and_blocks_user_prompt(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Archive');
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// repeat archive
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// append should be blocked
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/messages", [
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'blocked',
])->assertStatus(403);
}
public function test_get_message_respects_session_scope(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$s1 = $service->createSession('S1');
$s2 = $service->createSession('S2');
$msg1 = $service->appendMessage([
'session_id' => $s1->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
$this->withHeaders($headers)->getJson("/api/sessions/{$s1->session_id}/messages/{$msg1->message_id}")
->assertOk()
->assertJsonFragment(['message_id' => $msg1->message_id]);
// wrong session should 404
$this->withHeaders($headers)->getJson("/api/sessions/{$s2->session_id}/messages/{$msg1->message_id}")
->assertNotFound();
}
public function test_publish_to_redis_on_append(): void
{
Redis::shouldReceive('publish')->once()->andReturn(1);
$service = app(ChatService::class);
$session = $service->createSession('Redis Pub');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
}
public function test_sse_backlog_contains_messages(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('SSE Session');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello sse',
]);
$response = $this->withHeaders($headers)->get("/api/sessions/{$session->session_id}/sse?after_seq=0");
$response->assertOk();
$content = $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedResponse
? $response->streamedContent()
: $response->getContent();
$this->assertStringContainsString('id: 1', $content);
$this->assertStringContainsString('event: message', $content);
$this->assertStringContainsString('hello sse', $content);
}
} }