Files
Cyrene_v2/internal/runtime/history/store.go
T
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00

499 lines
24 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")
)
// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任
// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条
// 消息的 created_at。
type Message struct {
ID string
SessionID string
Role string
Content string
Mode string
Mood string
Image string
Thinking string
// Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台
// 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台
// (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认
// 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。
Platform string
// DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出
// (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的
// "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个
// 客户端共享 Platform,但 DeviceID 不同。
DeviceID string
// SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方
// 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者
// 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示
// 元数据 —— 身份绝不由该字段判定。
SenderName string
// AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如
// 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸
// 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空,
// 因此对 Web 消息它恒为空。
AdapterID string
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留
// 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨
// 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色
// 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全
// 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
// (项目红线)。
PersonaID string
// ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是
// 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地
// 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一
// 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前
// 逐字相同。
ClientMsgID string
CreatedAt int64
}
// SessionMeta 是一个持久化会话面向线上的元数据。
type SessionMeta struct {
ID string
Type string // "main" | "sub"(主会话 | 子会话)
Title string
CreatedAt int64
// Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限
// 绝不由它推导。
Pinned bool
// LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中
// 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求;
// 身份与权限绝不由它推导。
LastMessage *MessageSummary
// UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由
// ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端
// 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是
// 展示用 —— 身份与权限绝不由它推导。
UnreadCount int
// PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由
// 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、
// 情绪与思考预算。为空意味着「继承当前全局默认角色」
// (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定
// 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随
// 默认角色变更(design/22 §7-6)。
PersonaID string
}
// 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
}
// 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 迁移一致(列清单随后续迁移同步增长,见
// client_msg_id.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "history")
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
// ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引
// —— 部分唯一索引引用 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 := ensureClientMsgIDColumn(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}
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) 的部分唯一索引**原子**
// 裁决谁写下了这一行;命中冲突时不写入,改读回既有那一行返回(因此调用方拿到
// 的是权威消息,而不是一条并不存在的「刚插入的行」)。判重不在这里「先查再插」
// —— 那是两个并发重发都能通过检查、然后双双插入的竞态。
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, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if msg.ClientMsgID != "" {
query += ` ON CONFLICT DO NOTHING`
}
res, err := s.db.ExecContext(ctx, query,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.ClientMsgID, 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 {
return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID)
}
}
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
}
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
// 返回 ErrImmutableMain(design/16 §6)。
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
if sessionID == MainSessionID {
return ErrImmutableMain
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("history: begin delete %s: %w", sessionID, err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = ?`, sessionID); err != nil {
return fmt.Errorf("history: delete messages %s: %w", sessionID, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_sessions WHERE id = ? AND type = ?`, sessionID, SessionSub); err != nil {
return fmt.Errorf("history: delete session %s: %w", sessionID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("history: commit delete %s: %w", sessionID, err)
}
return 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, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_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.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &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,
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 '',
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)`