Files
Cyrene_v2/internal/services/gateway/history.go
T
AskaEth d64b26d499
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(chat): chat_messages.turn_id 列 + DTO(写入接线留待下一步)
迁移 0018:ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''
(与 persona_id / client_msg_id / document 同族:不用 NULL,空串 = 不属于任何已知回合 ⇒ 旧行零迁移、键省略)。
存储层全链路带 turn_id(列清单唯一一份 ⇒ List/ListAfter/Recent/Search/FindByClientMsgID 自动同源);
新增 history/turn_id.go:ensureTurnIDColumn(既有库幂等前滚)+ SetMessagesTurnID
(第二段补写:单事务参数化、先到先得 WHERE turn_id = ''、幂等、未知 id 忽略)。
DTO:historyMessage.TurnID(json turnId,omitempty)⇒ REST 列表 / admin 渠道日志 / WS 握手回放 /
message_persisted / channel_persisted / message_edited 一次全覆盖。
写入面:persistUserMessages 读 ctx 回合标识;persistAssistantReply 增可选尾参(既有调用点零改动);
withTurnID / turnIDFrom / assignTurnID / turnIDOfLeg。
⚠️ 编排器把 turnId 交给落库的 4 处(新起一路 / 续跑路 / 被 steer 吃进 / 助手回复)尚未接线
(那些文件当时被另一路占用);当前生产行为 turn_id 仍为 ''。清单已在切片报告里精确到文件:行。
13 新用例 + 5 条变异(INSERT 不带列 / DTO 不回填 / 去掉 omitempty / 去掉先到先得 / 不回滚前滚)。
为守 session_persist.go ≤500 行,turnId 一节拆到新文件 session_turn_id.go。
2026-10-05 09:46:10 +08:00

473 lines
20 KiB
Go
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.
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// historySessionSummary 是 GET /api/v1/sessions 响应中一个持久化会话
// (main 或 sub)的线上结构。
type historySessionSummary struct {
ID string `json:"id"`
Type string `json:"type"` // 取值:"main" | "sub"
Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"`
// Pinned 把会话浮到列表顶部(D5)。它只是排序元数据,
// 且始终存在,使客户端无需第二次调用就能渲染/排序。
Pinned bool `json:"pinned"`
// LastMessage 是会话最新消息的纯展示预览,使客户端
// 无需逐会话请求就能渲染对话行(例如 Android 会话列表)。
// 没有消息的会话会省略它。内容
// 已由存储截断。
LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
// UnreadCount 是比会话读游标(D4)更新的消息数,
// 在同一次列表查询中推导。它始终返回(无未读时为 0),
// 使客户端无需第二次调用就能渲染/清除角标。该
// 游标是会话作用域且单归属:在任一端已读会为
// 该账号的每一端清除角标。
UnreadCount int `json:"unreadCount"`
// PersonaID 是该会话所属的角色(design/22 §2/§4.5)。它是
// 「对话中由哪个角色应答」的唯一事实来源,客户端
// 用它为活动会话渲染角色的名字/头像。
// 为空时省略,而空意味着「继承全局默认角色」,因此
// 忽略该字段的旧客户端仍展示默认角色的视觉。
PersonaID string `json:"personaId,omitempty"`
}
// historyLastMessage 是会话摘要中最后消息预览的线上结构。
type historyLastMessage struct {
Role string `json:"role"` // 取值:"user" | "assistant"
Content string `json:"content"`
CreatedAt int64 `json:"createdAt"`
}
// historyMessage 是 GET /api/v1/sessions/{id}/messages 响应中一条持久化聊天
// 消息的线上结构。Mode 被携带,使前端无需猜测就渲染
// 气泡(design/16 §5.1);thinking 会被存储并返回给
// owner/admin(消息路由仅 owner/admin,因此绝不触达 viewer)。
type historyMessage struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Role string `json:"role"`
Content string `json:"content"`
Mode string `json:"mode,omitempty"`
Mood string `json:"mood,omitempty"`
Image string `json:"image,omitempty"`
// Documents 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
// 形状为 [{name,mime,size,chars,truncated,status}, ...]:一个回合最多 5 条,
// 顺序即客户端发送顺序。它们**只带引用与统计,绝不带抽取正文** —— 正文若
// 从这里回传,每翻一页历史都要背着它(G3 的教训)。没有附件(或附件列表为
// 空)的消息**省略该字段**,与引入文档之前逐字相同。
//
// ⚠️ 契约变更(S3.5):此前这里是单对象 `document`。单数形状从未进
// Docs/api-client.md(v0.59 待发、客户端未接),因此不留兼容期,直接换成
// 数组 —— 客户端按 `documents`(可为空/缺失)读取。
Documents []history.DocumentRef `json:"documents,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。
SenderName string `json:"senderName,omitempty"`
// AdapterID 是产出该消息的 IM 来源适配器(平台 + 账号)
// 注册名(例如 "kook:3bef...")。对 Web 来源的消息
// 为空。
AdapterID string `json:"adapterId,omitempty"`
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以
// 在保留上下文的同时切换角色,因此作者角色按消息
// 存储并返回,客户端用该角色的名字/头像渲染
// 气泡。为空时省略,而空意味着「全局默认角色」——
// 多角色之前的行读起来与以往完全一致。
PersonaID string `json:"personaId,omitempty"`
// ClientMsgID 是客户端自带的幂等键(Android 协同 §G4),原样回填,
// 因此客户端能把本地乐观气泡与服务端真相对齐(而不再靠「文本相等」
// 这类脆弱匹配),也能在重连后从读接口收敛。它为空时省略,而空意味着
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行)。
ClientMsgID string `json:"clientMsgId,omitempty"`
// TurnID 是这条消息所属的那一**路**回合的权威标识(design/15 §2.5.9):
// 编排器生成,与该路全部流帧 / `turn_begin` 帧壳上的 turnId **同值**。
// 同一路的 user 行与 assistant 行因此带同一个值,客户端据此
// - 精确配对「回复 ↔ 提问」;
// - 在**重连 / 回放**时从历史行恢复回复侧的分组小标(真并行下同一会话有两个
// 交错的回答,只按顺序渲染会配错)。
//
// 它为空时省略,而空意味着「这一行不属于任何已知的回合」:加入该列之前的历史
// 行、以及未经编排器的写入路径(IM 镜像等)都是这种情形,读起来与以往逐字
// 相同。排队 / 被转向吃进的消息在**落库那一刻**也还没有自己的路(那时它由哪
// 一路回答尚未确定),因此它们的那一帧 `message_persisted` 不带 turnId ——
// 客户端从 `turn_begin`(帧壳 turnId + data.messageIds)得知归属,行本身随后
// 由它实际被处理的那一路补写。
TurnID string `json:"turnId,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
// 优先)。它是前端的聊天历史会话列表(design/16 §2.2)。
func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
meta, err := s.history.ListSessions(r.Context())
if err != nil {
s.log.Error("history list sessions", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "sessions_failed", "failed to list sessions"))
return
}
out := make([]historySessionSummary, 0, len(meta))
for _, m := range meta {
out = append(out, historySessionSummary{
ID: m.ID,
Type: m.Type,
Title: m.Title,
CreatedAt: m.CreatedAt,
Pinned: m.Pinned,
PersonaID: m.PersonaID,
LastMessage: toHistoryLastMessage(m.LastMessage),
UnreadCount: m.UnreadCount,
})
}
writeJSON(w, http.StatusOK, out)
}
// toHistoryLastMessage 把已存的最后消息预览映射为其线上结构,
// 对没有消息的会话返回 nil(因此该字段从 JSON 中省略)。
func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
if m == nil {
return nil
}
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
// createSubSessionRequest 是 POST /api/v1/sessions 的请求体。请求体本身
// 可选(只想要普通会话的客户端不发送任何内容),personaId 是
// 新会话可选绑定的角色(design/22 §4.5)。它是设计的
// 优先级中「为新会话播种角色」的那一半:此处显式给出的角色
// 成为会话级绑定(唯一事实来源),而省略它则
// 保持 persona_id 为空串,使会话继承全局默认。
type createSubSessionRequest struct {
PersonaID string `json:"personaId"`
}
// handleCreateSubSession 创建一个新的子会话并返回其 sessionId。它是
// 用户侧写操作,因此与 /api/v1/chat 一样受 owner/admin 门控。可选的
// {"personaId":"..."} 请求体把新会话绑定到该角色;未知 id 会
// 以 400 拒绝,而不是创建一个会静默以
// 默认角色应答的会话。
func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
ownerID := ""
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
ownerID = claims.Sub
}
var req createSubSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if !s.requireKnownPersona(w, personaID) {
return
}
id, err := s.newSubSession(r.Context(), ownerID, personaID)
if err != nil {
if errors.Is(err, errPersonaBindingUnsupported) {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话角色绑定需要支持 personaId 的 HistoryStore", HTTPCode: http.StatusNotImplemented})
return
}
s.log.Error("history create sub session", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to create session"))
return
}
resp := map[string]any{"sessionId": id}
if personaID != "" {
resp["personaId"] = personaID
}
writeJSON(w, http.StatusCreated, resp)
}
// handleListMessages 用游标分页返回某会话的一页消息。
// 它是热分页历史端点(design/16 §2.2、§5.1)。支持两个互斥的
// 游标:
// - before(毫秒 createdAt):返回严格早于 before 的消息,最新优先
// —— 默认方向,用于向后翻历史;
// - after(毫秒 createdAt):返回严格新于 after 的消息,最旧优先
// —— 前向增量追赶,使已重连的客户端能拉取
// 从其已有的最新 createdAt 起错过的全部内容,而不受
// 向后翻的单页上限限制。
//
// limit 两个方向都默认 50(上限 200)。platform 在任一方向按
// 来源平台过滤该页。可选的 q 把该页切换为
// 会话作用域的内容搜索(最新优先,仍遵守 before/limit);q 与
// after 互斥,空 q 被忽略,因此普通分页
// 不变。同时给出 before 与 after(二者都 > 0)返回 400,
// after 非数字/为负或 limit/before 非法时亦然。
//
// **会话不存在 / 已删除 ⇒ `404 session_not_found`**(契约 v0.63,行为变更);
// 存在但确实没有消息仍是 `200 {"data":[]}`(见 history_session_guard.go)。
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
after, ok := parseAfterCursor(r)
if !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid after"))
return
}
if before > 0 && after > 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "before and after are mutually exclusive"))
return
}
platform := r.URL.Query().Get("platform")
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query != "" && after > 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "q and after are mutually exclusive"))
return
}
// 会话不存在 / 已删除 ⇒ 404 session_not_found(契约 v0.63):客户端据此把
// 「当前会话已不在」与「这是一条空会话」分开处理。**存在但没有消息**仍走
// 下面的空数组(见 history_session_guard.go)。
if s.rejectMissingSession(w, r, id) {
return
}
var msgs []history.Message
var err error
switch {
case query != "":
msgs, err = s.history.Search(r.Context(), id, query, before, limit)
case after > 0:
msgs, err = s.history.ListAfter(r.Context(), id, after, limit, platform)
default:
msgs, err = s.history.List(r.Context(), id, before, limit, platform)
}
if err != nil {
s.log.Error("history list messages", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
writeJSON(w, http.StatusOK, out)
}
// parseAfterCursor 读取非负的 "after" 查询参数(毫秒 createdAt
// 游标)。参数缺失/为空得到 (0, true) —— 无前向边界。非数字或
// 负值得到 (0, false),使调用方返回 400。
func parseAfterCursor(r *http.Request) (int64, bool) {
raw := r.URL.Query().Get("after")
if raw == "" {
return 0, true
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0, false
}
return v, true
}
// handleChannelPlatformLog 为某个 IM 平台跨其隔离(群聊/陌生人)
// 会话返回至多 limit 条消息,最新优先,供管理适配器日志页
// (design/16 §4.6 §4)。它排除固定的主 Web 会话,因此只浮现该平台的
// 非主人消息流;owner 私有镜像不进入该视图。
func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
platform := r.PathValue("platform")
if platform == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
msgs, err := s.history.ListPlatformLog(r.Context(), platform, before, limit)
if err != nil {
s.log.Error("history platform log", "err", err, "platform", platform)
writeError(w, apperr.Wrap(apperr.ErrInternal, "platform_log_failed", "failed to list platform log"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
writeJSON(w, http.StatusOK, out)
}
// handleChannelMessages 为管理适配器日志页返回至多 limit 条消息,
// 最新优先,覆盖三种粒度之一(design/16 §4.6 §4):单个
// 适配器配置(?adapter=kook:3bef...)、整个平台(?platform=kook),或
// 全部 IM 适配器总览(二者都不给,即 adapter_id 非空)。before/limit 复用
// 标准游标分页。主 Web 会话上的 owner 私有镜像在此
// 不被排除,因为它携带 adapter_id;每种粒度直接按
// adapter_id / platform 过滤。
func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
limitStr := r.URL.Query().Get("limit")
limit := historyDefaultLimit
if limitStr != "" {
v, err := strconv.Atoi(limitStr)
if err != nil || v < 1 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
limit = v
}
var before int64
if b := r.URL.Query().Get("before"); b != "" {
v, err := strconv.ParseInt(b, 10, 64)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
return
}
before = v
}
adapter := r.URL.Query().Get("adapter")
platform := r.URL.Query().Get("platform")
var msgs []history.Message
var err error
switch {
case adapter != "":
msgs, err = s.history.ListAdapterLog(r.Context(), adapter, before, limit)
case platform != "":
msgs, err = s.history.ListPlatformLog(r.Context(), platform, before, limit)
default:
msgs, err = s.history.ListAllLog(r.Context(), before, limit)
}
if err != nil {
s.log.Error("history channel messages", "err", err, "adapter", adapter, "platform", platform)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_messages_failed", "failed to list channel messages"))
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
if err := s.history.DeleteSession(r.Context(), id); err != nil {
if errors.Is(err, history.ErrImmutableMain) {
writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "the main session cannot be deleted"))
return
}
s.log.Error("history delete session", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to delete session"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// toHistoryMessage 把已存的 history.Message 映射为其线上结构。
func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{
ID: m.ID,
SessionID: m.SessionID,
Role: m.Role,
Content: m.Content,
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Documents: decodeHistoryDocuments(m.Document),
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
SenderName: m.SenderName,
AdapterID: m.AdapterID,
PersonaID: m.PersonaID,
// ClientMsgID 一并回填:它正是客户端拿来做乐观气泡对齐的键
// (协同 G4),读接口(GET /sessions/{id}/messages)与
// `message_persisted` 帧因此给出同一个答案。
ClientMsgID: m.ClientMsgID,
// TurnID 同理一并回填:读接口、WS 握手回放、`message_persisted` 与
// `message_edited` 四条面共用本映射,因此它们对「这条消息属于哪一路」
// 永远给出同一个答案(design/15 §2.5.9)。
TurnID: m.TurnID,
CreatedAt: m.CreatedAt,
}
}
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50
// decodeHistoryDocuments 解码已存消息的 document 列(引用数组,兼容 S2/S3 早期
// 的单对象旧形状)。空列表示这条消息没有附件,返回 nil 以省略该字段。列被写坏时
// (本服务端只会写合法 JSON,因此那只能是外部改动或未来格式变更)**绝不静默
// 当成没有附件** —— 「一方以为附件传上去了,另一方一无所知」正是 F2 要消灭的
// 误解;这时回一个标着 status=unreadable 的最小引用,让客户端看得见这条引用
// 读不出来。
func decodeHistoryDocuments(raw string) []history.DocumentRef {
refs, err := history.DecodeDocuments(raw)
if err != nil {
return []history.DocumentRef{{Status: history.DocumentStatusUnreadable}}
}
return refs
}