feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted) - agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列) - design/16 §4.4(平台) + §4.5(deviceID) 已入档 - 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿 - 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
1 parent
1bf60c1ae4
commit
37e98ec584
26 files changed
+828
-116
No files matched your search
@@ -37,6 +37,11 @@ const (
|
||||
// SessionMain and SessionSub classify a persistent session.
|
||||
SessionMain = "main"
|
||||
SessionSub = "sub"
|
||||
|
||||
// PlatformWeb is the source-platform tag for messages that originate on the Web
|
||||
// main session (design/16 §4.4). It is both the column default and the value
|
||||
// assigned when a caller leaves Message.Platform empty.
|
||||
PlatformWeb = "web"
|
||||
)
|
||||
|
||||
// MainSessionID is the fixed id of the one main session (design/16 §2.1). Sub session
|
||||
@@ -71,6 +76,17 @@ type Message struct {
|
||||
Mood string
|
||||
Image string
|
||||
Thinking string
|
||||
// Platform tags the source platform of a message (design/16 §4.4). The Web main
|
||||
// session is shared across platforms: messages that originate on the Web carry
|
||||
// "web", while IM-owner-private messages carry the channel platform (e.g.
|
||||
// "telegram", "qq-obv11", "weixin-ilink"). An empty Platform defaults to "web" so
|
||||
// legacy/explicitly-Web writes stay on the Web lane without a separate assignment.
|
||||
Platform string
|
||||
// DeviceID is a short, client-produced identifier for the concrete device/app
|
||||
// that sent the message (design/16 §4.5) — e.g. "web:chrome:a1b2c3" for a
|
||||
// browser, "weixin-ilink:conf0:wx_uid_123" for IM. It complements Platform: two
|
||||
// clients on the same platform share Platform but differ in DeviceID.
|
||||
DeviceID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
@@ -92,8 +108,12 @@ type HistoryStore interface {
|
||||
AppendFull(ctx context.Context, msg Message) (Message, error)
|
||||
// List returns up to limit messages for sessionID, newest first. before is a
|
||||
// millisecond timestamp cursor: when nonzero, only messages older than before are
|
||||
// returned (inclusive-free, matching the "before" bound on the frontend).
|
||||
List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error)
|
||||
// returned (inclusive-free, matching the "before" bound on the frontend). platform
|
||||
// is an optional source-platform filter (design/16 §4.4): when non-empty only
|
||||
// messages carrying that platform are returned; when empty all platforms are
|
||||
// returned (the Web client requests the full main session, an IM adapter asks for
|
||||
// its own platform only).
|
||||
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error)
|
||||
// Recent returns the most recent n messages for sessionID in oldest-first order,
|
||||
// for WS replay / context tail usage.
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
|
||||
@@ -175,11 +195,14 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
if msg.CreatedAt == 0 {
|
||||
msg.CreatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
if msg.Platform == "" {
|
||||
msg.Platform = PlatformWeb
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
||||
(id, session_id, role, content, mode, mood, image, thinking, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||
msg.Image, msg.Thinking, msg.CreatedAt)
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.CreatedAt)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
@@ -188,17 +211,24 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
|
||||
// List returns up to limit messages for sessionID, newest first. When before is nonzero
|
||||
// it bounds the query to messages strictly older than before, so a client can page upward
|
||||
// by passing the oldest message's created_at from the previous page. The returned slice
|
||||
// is non-nil. limit is clamped to [1, maxPageLimit].
|
||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error) {
|
||||
// by passing the oldest message's created_at from the previous page. When platform is
|
||||
// non-empty it further restricts the page to messages carrying that source platform
|
||||
// (design/16 §4.4: the Web client omits platform for the full main session, an IM
|
||||
// adapter passes its own platform to see only its lane). The returned slice is non-nil.
|
||||
// limit is clamped to [1, maxPageLimit].
|
||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at
|
||||
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)
|
||||
|
||||
@@ -228,7 +258,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
|
||||
if n < 1 {
|
||||
n = defaultPageLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at
|
||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
||||
@@ -342,11 +372,11 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
// scanMessage reads one chat_messages row. message columns are scanned positionally.
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, image, thinking string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &image, &thinking, &m.CreatedAt); err != nil {
|
||||
var mode, mood, image, thinking, platform, deviceID string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &image, &thinking, &platform, &deviceID, &m.CreatedAt); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Image, m.Thinking = mode, mood, image, thinking
|
||||
m.Mode, m.Mood, m.Image, m.Thinking, m.Platform, m.DeviceID = mode, mood, image, thinking, platform, deviceID
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -401,6 +431,8 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
mood TEXT,
|
||||
image TEXT,
|
||||
thinking TEXT,
|
||||
platform TEXT NOT NULL DEFAULT 'web',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestAppendAndListNewestFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50)
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestAppendAndListNewestFirst(t *testing.T) {
|
||||
if err := st.Append(ctx, empty); err != nil {
|
||||
t.Fatalf("append no ts: %v", err)
|
||||
}
|
||||
msgs, err = st.List(ctx, MainSessionID, 0, 50)
|
||||
msgs, err = st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after append: %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestAppendFullReturnsID(t *testing.T) {
|
||||
}
|
||||
|
||||
// The message is actually persisted under the returned id.
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50)
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
@@ -114,6 +114,133 @@ func TestAppendFullReturnsID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFullListPlatformRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An IM owner-private message carries its channel platform through the round
|
||||
// trip: AppendFull → List.
|
||||
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "从微信来", Platform: "weixin-ilink"})
|
||||
if err != nil {
|
||||
t.Fatalf("append full: %v", err)
|
||||
}
|
||||
if full.Platform != "weixin-ilink" {
|
||||
t.Fatalf("AppendFull platform = %q, want weixin-ilink", full.Platform)
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Platform != "weixin-ilink" || msgs[0].Content != "从微信来" {
|
||||
t.Fatalf("list round trip = %+v, want platform weixin-ilink", msgs)
|
||||
}
|
||||
|
||||
// A message without an explicit platform defaults to the web lane.
|
||||
web, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("append web default: %v", err)
|
||||
}
|
||||
if web.Platform != PlatformWeb {
|
||||
t.Fatalf("default platform = %q, want web", web.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A Web message tagged with a browser deviceID survives the AppendFull → List
|
||||
// round trip (design/16 §4.5).
|
||||
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "来自浏览器", DeviceID: "web:chrome:a1b2c3"})
|
||||
if err != nil {
|
||||
t.Fatalf("append full: %v", err)
|
||||
}
|
||||
if full.DeviceID != "web:chrome:a1b2c3" {
|
||||
t.Fatalf("AppendFull DeviceID = %q, want web:chrome:a1b2c3", full.DeviceID)
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].DeviceID != "web:chrome:a1b2c3" || msgs[0].Content != "来自浏览器" {
|
||||
t.Fatalf("list round trip = %+v, want DeviceID web:chrome:a1b2c3", msgs)
|
||||
}
|
||||
|
||||
// Recent (WS replay / context tail) reads the same device_id column.
|
||||
recent, err := st.Recent(ctx, MainSessionID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
if len(recent) != 1 || recent[0].DeviceID != "web:chrome:a1b2c3" {
|
||||
t.Fatalf("recent = %+v, want DeviceID web:chrome:a1b2c3", recent)
|
||||
}
|
||||
|
||||
// A message without an explicit deviceID persists the column default ('').
|
||||
om, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("append empty device: %v", err)
|
||||
}
|
||||
if om.DeviceID != "" {
|
||||
t.Fatalf("empty DeviceID = %q, want empty", om.DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPlatformFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed across the web lane (explicit + default) and one IM lane.
|
||||
for _, m := range []Message{
|
||||
{SessionID: MainSessionID, Role: RoleUser, Content: "web1", Platform: PlatformWeb, CreatedAt: 1000},
|
||||
{SessionID: MainSessionID, Role: RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000},
|
||||
{SessionID: MainSessionID, Role: RoleUser, Content: "web2", CreatedAt: 3000},
|
||||
} {
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("seed %q: %v", m.Content, err)
|
||||
}
|
||||
}
|
||||
|
||||
// No platform filter → all three (the empty-platform message defaulted to web).
|
||||
all, err := st.List(ctx, MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list all: %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("all = %d, want 3", len(all))
|
||||
}
|
||||
|
||||
// Filter to the IM lane → only its message.
|
||||
tg, err := st.List(ctx, MainSessionID, 0, 50, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("list telegram: %v", err)
|
||||
}
|
||||
if len(tg) != 1 || tg[0].Content != "tg" {
|
||||
t.Fatalf("telegram page = %+v, want [tg]", tg)
|
||||
}
|
||||
|
||||
// Filter to the web lane → web1 + web2 (the one left without an explicit
|
||||
// platform defaulted to web on insert).
|
||||
web, err := st.List(ctx, MainSessionID, 0, 50, PlatformWeb)
|
||||
if err != nil {
|
||||
t.Fatalf("list web: %v", err)
|
||||
}
|
||||
if len(web) != 2 {
|
||||
t.Fatalf("web page = %d, want 2 (%+v)", len(web), web)
|
||||
}
|
||||
|
||||
// The filter composes with the before cursor.
|
||||
older, err := st.List(ctx, MainSessionID, 2000, 50, PlatformWeb)
|
||||
if err != nil {
|
||||
t.Fatalf("list web before: %v", err)
|
||||
}
|
||||
if len(older) != 1 || older[0].Content != "web1" {
|
||||
t.Fatalf("web before page = %+v, want [web1]", older)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPaginationBefore(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
@@ -126,7 +253,7 @@ func TestListPaginationBefore(t *testing.T) {
|
||||
}
|
||||
|
||||
// First page: newest 2.
|
||||
page, err := st.List(ctx, MainSessionID, 0, 2)
|
||||
page, err := st.List(ctx, MainSessionID, 0, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("first page: %v", err)
|
||||
}
|
||||
@@ -134,7 +261,7 @@ func TestListPaginationBefore(t *testing.T) {
|
||||
t.Fatalf("first page = %+v, want [6000 5000]", page)
|
||||
}
|
||||
// Cursor on the page's oldest (5000) → older page [4000 3000].
|
||||
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2)
|
||||
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("next page: %v", err)
|
||||
}
|
||||
@@ -142,7 +269,7 @@ func TestListPaginationBefore(t *testing.T) {
|
||||
t.Fatalf("next page = %+v, want [4000 3000]", next)
|
||||
}
|
||||
// Past the start → empty.
|
||||
tail, err := st.List(ctx, MainSessionID, 1000, 50)
|
||||
tail, err := st.List(ctx, MainSessionID, 1000, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("tail: %v", err)
|
||||
}
|
||||
@@ -220,7 +347,7 @@ func TestDeleteSession(t *testing.T) {
|
||||
if err := st.DeleteSession(ctx, sub); err != nil {
|
||||
t.Fatalf("delete sub: %v", err)
|
||||
}
|
||||
msgs, err := st.List(ctx, sub, 0, 50)
|
||||
msgs, err := st.List(ctx, sub, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after delete: %v", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user