- 添加会话归档接口及相关服务逻辑,并确保幂等性 - 实现单条消息获取接口,校验消息所属会话 - 增加 SSE 增量推送与实时消息订阅功能 - 提供相关的测试用例覆盖新功能 - 更新接口文档,完善 OpenAPI 规范,新增多项示例
320 lines
10 KiB
PHP
320 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
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;
|
||
use Illuminate\Support\Facades\Redis;
|
||
use Illuminate\Support\Str;
|
||
|
||
class ChatService
|
||
{
|
||
|
||
/**
|
||
* 创建一个新聊天会话。
|
||
* @param string|null $name
|
||
* @return ChatSession
|
||
*/
|
||
public function createSession(string $name = null): ChatSession
|
||
{
|
||
return ChatSession::create([
|
||
'session_id' => (string) Str::uuid(),
|
||
'session_name' => $name,
|
||
'status' => ChatSessionStatus::OPEN,
|
||
'last_seq' => 0,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 根据会话ID获取聊天会话对象
|
||
*
|
||
* @param string $sessionId 会话唯一标识符
|
||
* @return ChatSession 返回对应的聊天会话对象
|
||
* @throws ModelNotFoundException 当找不到指定会话时抛出异常
|
||
*/
|
||
public function getSession(string $sessionId): ChatSession
|
||
{
|
||
return ChatSession::query()->whereKey($sessionId)->firstOrFail();
|
||
}
|
||
|
||
|
||
/**
|
||
* 将消息追加到指定的聊天会话中。
|
||
*
|
||
* @param array<string, mixed> $dto 包含消息详细信息的数据传输对象,包括以下键:
|
||
* - session_id: 聊天会话的唯一标识符
|
||
* - role: 消息角色(如发件人或收件人)
|
||
* - type: 消息类型
|
||
* - content: 消息内容(可选)
|
||
* - payload: 附加信息(可选)
|
||
* - reply_to: 被回复的消息 ID(可选)
|
||
* - dedupe_key: 消息去重键(可选)
|
||
* @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。
|
||
*/
|
||
public function appendMessage(array $dto): Message
|
||
{
|
||
$messageRef = null;
|
||
$isNew = false;
|
||
|
||
DB::transaction(function () use ($dto, &$messageRef, &$isNew) {
|
||
/** @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) {
|
||
$messageRef = $existing;
|
||
return;
|
||
}
|
||
}
|
||
|
||
$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();
|
||
$isNew = true;
|
||
} catch (QueryException $e) {
|
||
if ($this->isUniqueConstraint($e) && $dedupeKey) {
|
||
$existing = Message::query()
|
||
->where('session_id', $session->session_id)
|
||
->where('dedupe_key', $dedupeKey)
|
||
->first();
|
||
|
||
if ($existing) {
|
||
$messageRef = $existing;
|
||
return;
|
||
}
|
||
}
|
||
|
||
throw $e;
|
||
}
|
||
|
||
$session->update([
|
||
'last_seq' => $newSeq,
|
||
'last_message_id' => $message->message_id,
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
$messageRef = $message;
|
||
|
||
if ($isNew) {
|
||
DB::afterCommit(fn () => $this->publishMessageAppended($message));
|
||
}
|
||
});
|
||
|
||
/** @var Message $messageRef */
|
||
return $messageRef;
|
||
}
|
||
|
||
/**
|
||
* 列出指定会话中指定序号之后的消息。
|
||
*
|
||
* @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
|
||
|
||
return Message::query()
|
||
->where('session_id', $sessionId)
|
||
->where('seq', '>', $afterSeq)
|
||
->orderBy('seq')
|
||
->limit($limit)
|
||
->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;
|
||
}
|
||
|
||
/**
|
||
* 获取会话列表
|
||
*
|
||
* @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 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
|
||
{
|
||
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';
|
||
}
|
||
}
|