Files
Cyrene_v2/internal/services/gateway/history.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

412 lines
16 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"`
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"`
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 非法时亦然。
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
}
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,
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
SenderName: m.SenderName,
AdapterID: m.AdapterID,
PersonaID: m.PersonaID,
CreatedAt: m.CreatedAt,
}
}
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50