Files
Cyrene_v2/internal/runtime/history/store.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

430 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 history 实现 DevDocs/design/16-会话持久化与主次级会话.md 描述的持久化
// 聊天历史会话模型。
//
// 它是存储层,而不是编排/上下文层:它持久化原始消息流(chat_messages)与持久化
// 会话记录(chat_sessions),使 Web 前端能请求并展示历史,子会话也能被创建、列出
// 与删除。它刻意不碰记忆(design/16 §3.2:记忆是角色作用域的,住在
// runtime/memory)、不构建上下文(runtime/loop),也不调度回合
// (gateway/session_orchestrator)。
//
// 会话模型(design/16 §2.1):有且只有一个固定主会话(id "web")与任意数量的子
// 会话(id "web:<uuid>")。主会话与子会话共享同一角色,因而共享同一记忆;本包
// 只建模会话与消息。
package history
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 会话角色与类型。这些就是持久化在 role / type 列中的取值。
const (
// RoleUser、RoleAssistant、RoleSystem 归类一条消息的作者。
RoleUser = "user"
RoleAssistant = "assistant"
RoleSystem = "system"
// SessionMain 与 SessionSub 归类一个持久化会话。
SessionMain = "main"
SessionSub = "sub"
// PlatformWeb 是源自 Web 主会话的消息所用的来源平台标签
// (design/16 §4.4)。它既是该列的默认值,也是调用方未设置
// Message.Platform 时赋的值。
PlatformWeb = "web"
)
// MainSessionID 是那个唯一主会话的固定 id(design/16 §2.1)。子会话 id 为
// MainSessionID + ":" + uuid。
const MainSessionID = "web"
// defaultPageLimit 是调用方未传限制时使用的页大小。
const defaultPageLimit = 50
// maxPageLimit 为客户端传入的 limit 设上界,使单个请求无法耗尽内存。
const maxPageLimit = 200
// 区分 history 包各类失败的哨兵错误。调用方用 %w 包装它们,使 errors.Is 能穿透
// 错误链。
var (
// ErrNilDB 在 NewStore 收到 nil 的 *sql.DB 时返回。
ErrNilDB = errors.New("history: nil db")
// ErrImmutableMain 在调用方试图删除固定主会话时返回。
ErrImmutableMain = errors.New("history: the main session cannot be deleted")
// ErrMessageNotFound 在没有 chat_messages 行携带所请求 id 时由
// UpdateMessage/DeleteMessage 返回。
ErrMessageNotFound = errors.New("history: message not found")
)
// HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
// 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
// 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
// (internal/services/gateway 的 contracts.go),这样别处更窄的存储替身不必
// 实现一个它从不推进的游标。*Store 同时满足两者。
type HistoryStore interface {
// Append 持久化 msg,在调用方省略时分配 id 与时间戳。
Append(ctx context.Context, msg Message) error
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息(含分配的
// id/时间戳),使调用方能广播它做跨端同步。
AppendFull(ctx context.Context, msg Message) (Message, error)
// List 按最新优先返回 sessionID 的至多 limit 条消息。before 是毫秒时间戳
// 游标:非零时只返回早于 before 的消息(不含边界,与前端 "before" 语义
// 一致)。platform 是可选的来源平台过滤(design/16 §4.4):非空时只返回
// 携带该平台的消息;为空时返回全部平台(Web 客户端请求完整主会话,IM
// 适配器只请求自己的平台)。
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error)
// ListAfter 按最旧优先返回 sessionID 中严格新于 after(created_at >
// after)的至多 limit 条消息,使重连的客户端能从自己已见过的最新
// createdAt 向前拉取错过的消息。platform 的过滤同 List。limit 钳制到
// [1, maxPageLimit]。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error)
// Recent 按最旧优先返回 sessionID 最近的 n 条消息,供 WS 回放 / 上下文
// 尾部使用。
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
// ListPlatformLog 按最新优先返回某个 IM 平台跨所有非主(隔离)会话的至多
// limit 条消息,供适配器日志页使用(design/16 §4.6 §4)。它排除固定主 Web
// 会话,从而在不带主人私聊镜像的情况下浮现该平台的非主人消息流
// (群聊 + 陌生人私聊)。before 是毫秒游标;0 表示最新一页。
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
// ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。与
// ListPlatformLog 不同,它不排除主 Web 会话:主人的 IM 私聊住在主会话里
// 但仍携带 adapter_id,因此按适配器的日志包含主人镜像。before 是毫秒
// 游标;0 表示最新一页。adapterID 为空时返回空切片。
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error)
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多
// limit 条消息,供适配器总览页使用。before 是毫秒游标;0 表示最新一页。
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
// DeleteSession 删除一个子会话及其全部消息。主会话不可变,返回
// ErrImmutableMain。
DeleteSession(ctx context.Context, sessionID string) error
// Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息
// (ASCII 大小写不敏感的 LIKE,%/_/\ 已转义)。before 与 List 是同一个
// 毫秒游标;limit 钳制到 [1, maxPageLimit]。空 query 返回空切片(调用方
// 退回 List 做普通翻页)。Search 始终限定在单个会话内,因此隔离的
// 群聊/陌生人会话绝不浮现在另一个会话的结果中。
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error)
// UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
// 一样,使调用方能无需二次读取就广播它)。只有 content 变化;role、时间
// 戳、platform 保持已存值。未知 id 返回 ErrMessageNotFound。
UpdateMessage(ctx context.Context, id, content string) (Message, error)
// DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
DeleteMessage(ctx context.Context, id string) error
// RenameSession 设置会话的显示标题。主会话与子会话都可重命名(标题只是
// 展示元数据);未知 id 返回 ErrSessionNotFound。
RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned 置顶或取消置顶一个会话。置顶只是展示/排序元数据;主会话与
// 子会话都可置顶。未知 id 返回 ErrSessionNotFound。
SetPinned(ctx context.Context, sessionID string, pinned bool) error
// NewSubSession 创建一个子会话并返回其 id(MainSessionID:uuid)。它不绑定
// 显式角色,因此新会话继承全局默认(persona_id 为空);*Store 另提供
// NewSubSessionFor 这个绑定角色的变体(design/22 §4.1),本窄接口刻意
// 省略它,因为 channelbridge 与 backthink 都不创建会话。
NewSubSession(ctx context.Context, ownerID string) (string, error)
// ListSessions 先返回置顶会话,再返回主会话,最后返回每个子会话(最旧
// 优先)。
ListSessions(ctx context.Context) ([]SessionMeta, error)
}
// Store 是基于 SQLite 的聊天历史存储。它拥有 chat_sessions 与 chat_messages
// 两张表。并发交给 *sql.DB(可安全并发使用),因此 Store 不再持有额外的锁。
// 每条查询都参数化,读取时显式列出列(不用 SELECT *);读取都接受 context。
type Store struct {
db *sql.DB
log *slog.Logger
// refs 是文档引用计数的写入面(切片 S7,见 refs.go)。未注入时为 nil,
// 此时消息读写照旧、只是不维护 documents.ref_count。
refs DocumentRefCounter
}
// rowScanner 由 *sql.Row 与 *sql.Rows 同时满足,因此单个 scanMessage 辅助函数
// 就能服务 List 与 Recent。
type rowScanner interface {
Scan(dest ...any) error
}
// NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,前滚
// 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
//
// opts 是可选的接线面(见 refs.go):组合根用 WithDocumentRefs 注入引用计数写入
// 面;不传时 Store 的行为与切片 S7 之前逐字相同。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger, opts ...StoreOption) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "history")
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
// ensureMessageColumns 把早于这些列(client_msg_id / document / turn_id)建出的
// 既有表前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不
// 存在时建它必然失败。
for _, ddl := range []string{
createSessionsTable,
createMessagesTable,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("history: apply schema: %w", err)
}
}
if err := ensureMessageColumns(ctx, db); err != nil {
return nil, err
}
for _, ddl := range []string{
createMessagesIndex,
createClientMsgIDIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("history: apply schema: %w", err)
}
}
s := &Store{db: db, log: log}
for _, opt := range opts {
opt(s)
}
if err := s.ensureMainSession(ctx); err != nil {
return nil, err
}
return s, nil
}
// Append 持久化 msg,在调用方把 id/CreatedAt 留空/留零时分配新的 id 与现在的
// 毫秒时间戳。它返回包装了操作名的错误。需要拿回生成的 id/时间戳的调用方应
// 改用 AppendFull。
func (s *Store) Append(ctx context.Context, msg Message) error {
_, err := s.insert(ctx, msg)
return err
}
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息 —— 即调用方留空/
// 留零时存储分配的 id 与 CreatedAt —— 使调用方能广播它做跨端实时同步
// (design/16 §4.2),无需二次读取。
func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
return s.insert(ctx, msg)
}
// insert 是 Append/AppendFull 共用的主体。它在调用方把 id/CreatedAt 留空/留零时
// 分配新的 id 与现在的毫秒时间戳,然后插入该行并返回得到的消息。
//
// msg.ClientMsgID 非空时(Android 协同 §G4,客户端幂等键)写入走
// ON CONFLICT DO NOTHING,由 (session_id, client_msg_id) 的部分唯一索引**原子**
// 裁决谁写下了这一行;命中冲突时不写入,改读回既有那一行返回(因此调用方拿到
// 的是权威消息,而不是一条并不存在的「刚插入的行」)。判重不在这里「先查再插」
// —— 那是两个并发重发都能通过检查、然后双双插入的竞态。
//
// 该行携带文档引用时(msg.Document 非空),引用计数的 +1 与这一行**同一个事务**
// 落定(切片 S7):命中幂等冲突时不计数(那一行不是本次写的),计数写失败则整条
// 消息回滚 —— 绝不留下「消息在库里、计数没加」这种让 GC 误删仍被引用文件的窗口。
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.ID == "" {
id, err := newID()
if err != nil {
return Message{}, fmt.Errorf("history: append id: %w", err)
}
msg.ID = id
}
if msg.CreatedAt == 0 {
msg.CreatedAt = time.Now().UnixMilli()
}
if msg.Platform == "" {
msg.Platform = PlatformWeb
}
if msg.Role == RoleUser {
s.ensureTitle(ctx, msg.SessionID, msg.Content)
}
query := `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if msg.ClientMsgID != "" {
query += ` ON CONFLICT DO NOTHING`
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, query,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
msg.PersonaID, msg.ClientMsgID, msg.TurnID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
if msg.ClientMsgID != "" {
n, err := res.RowsAffected()
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
if n == 0 {
// 冲突:本次没有写下任何行,因此也没有引用要记。先释放事务再读回
// 权威行(连接池只有一条连接,读回必须等事务让出它)。
_ = tx.Rollback()
return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID)
}
}
if err := s.retainDocuments(ctx, tx, msg.Document); err != nil {
return Message{}, err
}
if err := tx.Commit(); err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
return msg, nil
}
// List 按最新优先返回 sessionID 的至多 limit 条消息。before 非零时把查询限制为
// 严格早于 before 的消息,使客户端可以传入上一页最旧那条消息的 created_at 向上
// 翻页。platform 非空时把该页进一步限制为携带该来源平台的消息(design/16 §4.4:
// Web 客户端省略 platform 以拿到完整主会话,IM 适配器传入自己的平台只看自己的
// 通道)。返回切片非 nil。limit 钳制到 [1, maxPageLimit]。
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE session_id = ?`
args := []any{sessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
if platform != "" {
query += ` AND platform = ?`
args = append(args, platform)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: list %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan list %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate list %s: %w", sessionID, err)
}
return msgs, nil
}
// NewSubSession 创建一个不绑定显式角色的子会话并返回其 id
// (MainSessionID:uuid)。新会话因此继承全局默认角色(persona_id 为空);要在
// 创建时绑定角色请用 NewSubSessionFor。ownerID 为接口完整性而接受(Web 主人
// 由 JWT 识别,不按会话存储);它只以 debug 级别记日志,不被持久化。
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
return s.NewSubSessionFor(ctx, ownerID, "")
}
// ensureMainSession 在固定主会话行缺失时插入它,使 ListSessions 在任何消息写入
// 之前也能报告出主会话。
func (s *Store) ensureMainSession(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO chat_sessions (id, type, title, created_at)
VALUES (?, ?, ?, ?)`, MainSessionID, SessionMain, "", time.Now().UnixMilli())
if err != nil {
return fmt.Errorf("history: ensure main session: %w", err)
}
return nil
}
// messageColumns 是 scanMessage 期望的 chat_messages 列清单,也是每条读取
// 语句(List/ListAfter/Recent/Search/messageByID/FindByClientMsgID)共用的
// **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。
// 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在
// 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。
const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at`
// scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
func scanMessage(sc rowScanner) (Message, error) {
var m Message
var mode, mood, platform, deviceID, senderName, adapterID string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.TurnID, &m.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
return m, nil
}
// clampLimit 把 limit 钳制到 [1, maxPageLimit];调用方传入的 0 或负数回退到
// defaultPageLimit。
func clampLimit(limit int) int {
if limit < 1 {
return defaultPageLimit
}
if limit > maxPageLimit {
return maxPageLimit
}
return limit
}
// newID 返回一个 128 位密码学随机的 id,以十六进制编码。它避免引入 UUID 依赖,
// 使本包保持自足。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("history: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个什么都不写的日志器。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// Schema DDL。internal/db 里的 0003 迁移是领域表的权威机制,但这里刻意再幂等
// 地发出一次,使存储自足、可从任意 *sql.DB 使用(与 memory 和 session 包一致)。
const createSessionsTable = `
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
read_at INTEGER NOT NULL DEFAULT 0,
persona_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
const createMessagesTable = `
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
document TEXT NOT NULL DEFAULT '',
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
client_msg_id TEXT NOT NULL DEFAULT '',
turn_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
const createMessagesIndex = `
CREATE INDEX IF NOT EXISTS idx_chat_msgs_session_created
ON chat_messages (session_id, created_at DESC)`