Files
Cyrene_v2/internal/services/gateway/history.go
T
AskaEth d37d4a899b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): 余下 3 处行尾注释中文化(存量注释债结项)
- 事实订正:任务书说「gateway 全包中文注释 ≈ 0」是 2026-10-03 的旧状态;该债早已分批还清(074ffc4 / 355f81d / fe1ff33)。本次清点:70 个非测试文件 / 3493 行 // 注释,仅 6 行无中文。

- 翻 3 行(都是行尾枚举提示,代码前缀逐字节不变):admin_write_iot_automations.go:107 (cron|event)、history.go:20 (main|sub)、history.go:47 (user|assistant);另 3 行是中文多行句子的纯标识符/帧名续行,按规则不翻。

- 机械校验:除这 3 组行尾注释外零输出(即整包 diff 无任何非注释行、非空行改动);gofmt -l 干净(改动前也干净);go build/vet 全树绿;gateway 包测试绿。

- 主会话独立复核:机械校验输出恰为 3 对(代码部分逐字相同)、gofmt 干净、go test ./internal/services/gateway/... 绿。

- 文档订正:开发习惯与约定.md 的「gateway ≈ 0 中文注释」已改写成结项事实(测试文件里的存量注释仍未清点,另记)。
2026-10-04 18:57:58 +08:00

446 lines
18 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"`
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,
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,
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
}