Files
ars-backend/app/Services/ChatService.php
ROOG 6356baacc0 main: 增强会话功能,支持消息管理和接口文档
- 添加 `last_message_id` 字段至 `chat_sessions` 表,更新其关联索引
- 实现会话更新接口,支持修改名称与状态并添加验证逻辑
- 增加会话列表接口,支持状态过滤与关键字查询
- 提供会话和消息相关的资源类和请求验证类
- 扩展 `ChatService` 服务层逻辑以处理会话更新和消息附加
- 编写测试用例以验证新功能的正确性
- 增加接口文档及 OpenAPI 规范文件,覆盖新增功能
- 更新数据库播种器,添加默认用户
2025-12-14 20:20:27 +08:00

251 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\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
{
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,
'last_message_id' => $message->message_id,
'updated_at' => now(),
]);
return $message;
});
}
/**
* 列出指定会话中指定序号之后的消息。
*
* @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();
}
/**
* 获取会话列表
*
* @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) {
$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';
}
}