Files
Cyrene_v2/internal/services/gateway/history.go
T
AskaEth f43751fd07
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(history): 消息正向增量拉取(A5) — GET /sessions/{id}/messages?after=
- history/list_after.go: ListAfter(升序, created_at>after, 走覆盖索引, rowid兜底同毫秒)
- HistoryStore 新增 ListAfter(store+fakes同步)
- gateway history.go: handleListMessages解析after分流; after+before互斥400; 负数/非数字400
- 新增测试(list_after/history_after), build/vet/test绿+交叉编译
- Docs/api-client v0.26 + api-reference §3.4 同步
2026-09-08 21:49:12 +08:00

397 lines
15 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"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"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 is the wire shape of one persistent session (main or sub) in the
// GET /api/v1/sessions response.
type historySessionSummary struct {
ID string `json:"id"`
Type string `json:"type"` // "main" | "sub"
Title string `json:"title,omitempty"`
CreatedAt int64 `json:"createdAt"`
// LastMessage is a display-only preview of the session's newest message, so the
// client can render a conversation row (e.g. the Android session list) without a
// per-session request. It is omitted for a session with no messages. Content is
// already truncated by the store.
LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
}
// historyLastMessage is the wire shape of the last-message preview in a session summary.
type historyLastMessage struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
CreatedAt int64 `json:"createdAt"`
}
// historyMessage is the wire shape of one persisted chat message in the
// GET /api/v1/sessions/{id}/messages response. Mode is carried so the frontend renders
// the bubble without guessing (design/16 §5.1); thinking is stored and returned to the
// owner/admin (the messages route is owner/admin-only, so it never reaches a 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 is the human-readable sender display name of a third-party
// message (group/stranger, design/16 §4.6). It is blank for the owner's
// private chat on the main session, so empty means "labeled as the owner".
SenderName string `json:"senderName,omitempty"`
// AdapterID is the adapter (platform + account) registration name of the IM
// source that produced the message (e.g. "kook:3bef..."). It is blank for
// Web-originated messages.
AdapterID string `json:"adapterId,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions returns the main session first, then every sub session (oldest
// first). It is the chat-history session list for the frontend (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,
LastMessage: toHistoryLastMessage(m.LastMessage),
})
}
writeJSON(w, http.StatusOK, out)
}
// toHistoryLastMessage maps a stored last-message preview to its wire shape, returning
// nil for a session that has no messages (so the field is omitted from the JSON).
func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
if m == nil {
return nil
}
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
// handleCreateSubSession creates a new sub session and returns its sessionId. It is a
// user-side write, so it is owner/admin-gated like /api/v1/chat.
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
}
id, err := s.history.NewSubSession(r.Context(), ownerID)
if err != nil {
s.log.Error("history create sub session", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to create session"))
return
}
writeJSON(w, http.StatusCreated, map[string]any{"sessionId": id})
}
// handleListMessages returns a page of messages for a session with cursor pagination.
// It is the hot-paging history endpoint (design/16 §2.2, §5.1). Two mutually exclusive
// cursors are supported:
// - before (millisecond createdAt): newest-first page of messages strictly older than
// before — the default, used to page backwards through history;
// - after (millisecond createdAt): oldest-first page of messages strictly newer than
// after — forward incremental catch-up, so a client that reconnected can pull
// everything it missed from the newest createdAt it already has, without the
// one-page limit of a backward walk.
//
// limit defaults to 50 (clamped to 200) for both directions. platform filters the page
// by source platform in either direction. Supplying both before and after (both > 0)
// answers 400, as does a non-numeric/negative after or an invalid 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")
var msgs []history.Message
var err error
if after > 0 {
msgs, err = s.history.ListAfter(r.Context(), id, after, limit, platform)
} else {
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 reads the non-negative "after" query param (a millisecond createdAt
// cursor). An absent/empty param yields (0, true) — no forward bound. A non-numeric or
// negative value yields (0, false) so the caller answers 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 returns up to limit messages for an IM platform across its
// isolated (group/stranger) sessions, newest first, for the admin adapter log page
// (design/16 §4.6 §4). It excludes the fixed main web session, so only the platform's
// non-owner message stream is surfaced; the owner-private mirror stays out of this view.
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 returns up to limit messages for the admin adapter log page,
// newest first, across one of three granularities (design/16 §4.6 §4): a single
// adapter config (?adapter=kook:3bef...), a whole platform (?platform=kook), or the
// full IM adapter overview (neither, so adapter_id != ''). before/limit reuse the
// standard cursor pagination. The owner-private mirror on the main web session is
// NOT excluded here because it carries the adapter_id; each granularity filters on
// adapter_id / platform directly.
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})
}
// renameSessionRequest is the PATCH /api/v1/sessions/{id} body.
type renameSessionRequest struct {
Title string `json:"title"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
// overflows. It is a display-only bound: identity and permissions never derive from
// the title (project red line).
const maxSessionTitleRunes = 80
// handleRenameSession renames a persistent session (main or sub). The title is
// display-only metadata, so unlike DELETE it is allowed on the fixed main session. An
// empty/whitespace title or one longer than maxSessionTitleRunes answers 400; an
// unknown session id answers 404. It is owner/admin-gated like the other history
// writes.
func (s *Server) handleRenameSession(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
}
var req renameSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is too long (max 80 characters)"))
return
}
if err := s.history.RenameSession(r.Context(), id, title); err != nil {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error("history rename session", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to rename session"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": title})
}
// toHistoryMessage maps a stored history.Message to its wire shape.
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,
CreatedAt: m.CreatedAt,
}
}
// historyDefaultLimit is the page size used when the client omits the limit query param.
const historyDefaultLimit = 50