feat(channels): 管理面板消息日志——chat_messages 加 adapter_id + 配置级/平台/总览三粒度端点
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端:
- 迁移 0007:chat_messages 加 adapter_id 列 + 索引
- history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name())
- channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a
- Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留
- 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId
- 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言

前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
This commit is contained in:
Aska committed 2026-09-07 17:25:23 +08:00
1 parent c1bd740f7c
commit 2e044d1d19
15 files changed
+560 -41

No files matched your search

+106 -9
View File
@@ -94,6 +94,12 @@ type Message struct {
// the main session (the owner is never labeled). It is display metadata only —
// identity is never decided from this field.
SenderName string
// AdapterID is the adapter (platform + account) registration name of the IM
// source that produced the message, e.g. "kook:3bef..." for a multi-account
// channels.json registration or a bare platform name (e.g. "kook") when the
// account comes from env. IM adapter-originated messages carry it; the Web main
// session leaves it empty, so it is always '' for Web messages.
AdapterID string
CreatedAt int64
}
@@ -130,6 +136,18 @@ type HistoryStore interface {
// message stream (groups + stranger private chats) is surfaced without the
// owner-private mirror. before is a millisecond cursor; 0 means newest page.
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
// account registration name, e.g. "kook:3bef..."), newest first, for the
// per-adapter log page. Unlike ListPlatformLog it does NOT exclude the main web
// session: an owner-private IM chat lives in the main session but still carries
// the adapter_id, so a per-adapter log includes the owner mirror. before is a
// millisecond cursor; 0 means the newest page. An empty adapterID returns an
// empty slice.
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error)
// ListAllLog returns up to limit messages across every IM adapter source
// (adapter_id != ''), newest first, for the adapter overview page. before is a
// millisecond cursor; 0 means the newest page.
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
// DeleteSession removes a sub session and all its messages. The main session is
// immutable and returns ErrImmutableMain.
DeleteSession(ctx context.Context, sessionID string) error
@@ -212,10 +230,10 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
msg.Platform = PlatformWeb
}
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.CreatedAt)
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
@@ -231,7 +249,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
// 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, platform, device_id, sender_name, created_at
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE session_id = ?`
args := []any{sessionID}
if before > 0 {
@@ -272,7 +290,7 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
// only strictly older messages are returned. The returned slice is non-nil.
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE platform = ? AND session_id != ?`
args := []any{platform, MainSessionID}
if before > 0 {
@@ -302,13 +320,91 @@ func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int
return msgs, nil
}
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
// account registration name, e.g. "kook:3bef..."), newest first, for the per-adapter
// log page. It does NOT exclude the main web session: an owner-private IM chat lives
// in the main session but still carries the adapter_id, so a per-adapter log includes
// the owner mirror. An empty adapterID returns an empty (non-nil) slice. before is a
// millisecond cursor; when nonzero only strictly older messages are returned. The
// returned slice is non-nil.
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if adapterID == "" {
return make([]Message, 0), nil
}
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE adapter_id = ?`
args := []any{adapterID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
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: adapter log %s: %w", adapterID, 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 adapter log %s: %w", adapterID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate adapter log %s: %w", adapterID, err)
}
return msgs, nil
}
// ListAllLog returns up to limit messages across every IM adapter source
// (adapter_id != ''), newest first, for the adapter overview page. before is a
// millisecond cursor; when nonzero only strictly older messages are returned. The
// returned slice is non-nil.
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
FROM chat_messages WHERE adapter_id != ''`
var args []any
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
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: all adapter log: %w", 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 all adapter log: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate all adapter log: %w", err)
}
return msgs, nil
}
// Recent returns the most recent n messages for sessionID in oldest-first order (the
// tail-most window), for WS replay / context tail. The returned slice is non-nil.
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
if n < 1 {
n = defaultPageLimit
}
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_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)
@@ -422,11 +518,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, platform, deviceID, senderName string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &m.CreatedAt); err != nil {
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.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName = mode, mood, platform, deviceID, senderName
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
return m, nil
}
@@ -484,6 +580,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
+113
View File
@@ -423,3 +423,116 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
t.Fatalf("web platform log = %+v, err=%v; want empty", log, err)
}
}
func TestAdapterLogFiltersByAdapter(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// Seed across two adapters, an empty-adapter web message, and a main-session
// owner-private mirror that still carries the adapter (design/16 §4.4).
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: "sess-g1", Role: RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
{SessionID: "sess-g2", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", SenderName: "李四", CreatedAt: 3000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 4000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// Per-adapter log includes BOTH the owner-private main mirror and the group
// message, newest first.
kook, err := st.ListAdapterLog(ctx, "kook:3bef", 0, 50)
if err != nil {
t.Fatalf("adapter log kook:3bef: %v", err)
}
if len(kook) != 2 {
t.Fatalf("kook adapter log = %d, want 2 (%+v)", len(kook), kook)
}
if kook[0].Content != "kook-group" || kook[0].SessionID != "sess-g1" {
t.Fatalf("kook[0] = %+v, want newest kook-group", kook[0])
}
if kook[1].Content != "kook-owner" || kook[1].SessionID != MainSessionID {
t.Fatalf("kook[1] = %+v, want owner mirror on main session", kook[1])
}
// A different adapter yields only its own lane.
tg, err := st.ListAdapterLog(ctx, "telegram:conf0", 0, 50)
if err != nil {
t.Fatalf("adapter log telegram:conf0: %v", err)
}
if len(tg) != 1 || tg[0].Content != "tg-group" {
t.Fatalf("telegram adapter log = %+v, want [tg-group]", tg)
}
// An unknown / empty adapter yields a non-nil empty slice.
none, err := st.ListAdapterLog(ctx, "kook:unknown", 0, 50)
if err != nil {
t.Fatalf("adapter log unknown: %v", err)
}
if none == nil || len(none) != 0 {
t.Fatalf("unknown adapter log = %#v, want non-nil empty slice", none)
}
empty, err := st.ListAdapterLog(ctx, "", 0, 50)
if err != nil {
t.Fatalf("adapter log empty: %v", err)
}
if empty == nil || len(empty) != 0 {
t.Fatalf("empty adapter log = %#v, want non-nil empty slice", empty)
}
// The before cursor pages backward through the adapter's stream.
older, err := st.ListAdapterLog(ctx, "kook:3bef", 2000, 50)
if err != nil {
t.Fatalf("adapter log before: %v", err)
}
if len(older) != 1 || older[0].Content != "kook-owner" {
t.Fatalf("kook before page = %+v, want [kook-owner]", older)
}
}
func TestAllLogSurfacesOnlyAdapterMessages(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 2000},
{SessionID: "sess-g1", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// The overview only surfaces IM adapter sources; the web message is excluded.
all, err := st.ListAllLog(ctx, 0, 50)
if err != nil {
t.Fatalf("all log: %v", err)
}
if len(all) != 2 {
t.Fatalf("all log = %d, want 2 (%+v)", len(all), all)
}
if all[0].Content != "tg-group" || all[1].Content != "kook-owner" {
t.Fatalf("all log order = %+v, want [tg-group kook-owner]", all)
}
// The web message never leaks into the adapter overview.
for _, m := range all {
if m.Content == "web-msg" {
t.Fatalf("web message leaked into adapter overview: %+v", m)
}
}
// An empty store yields a non-nil empty slice.
st2 := newStore(t)
e, err := st2.ListAllLog(ctx, 0, 50)
if err != nil {
t.Fatalf("empty all log: %v", err)
}
if e == nil || len(e) != 0 {
t.Fatalf("empty all log = %#v, want non-nil empty slice", e)
}
}