diff --git a/AGENTS.md b/AGENTS.md index da3618d..96e28ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ 事件聚合资料:互动用户展示真实昵称与 UID,以平台返回的 secUID 链接主页,不使用数字 UID 猜测主页;发生时间在前、接收时间紧邻其后,所属账号及筛选选项仅显示昵称。对应作品展示 48px 小封面并链接抖音作品详情,图文使用 note 地址;缺封面保留作品入口,缺资料明确标示。通知中的作品封面复用 `<作者 UID>/<作品 ID>.<图片扩展名>` 本地缓存,不创建占位账号或作品,不回退展示远程图片。历史核对可补齐已保存事件的用户与作品资料,但只更新同一互动 UID、同一作品 ID 的资料,不改原事件时间、内容、历史标记或去重规则;封面下载失败单独记录和展示,不能阻断通知保存,也不能伪装成已缓存。 -私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话列表与消息区联系人展示聊天客户端专用资料接口返回的真实昵称,支持普通用户与 AI 分身,不用普通主页接口代替、不用 UID 或会话标题占位;平台昵称本身为“用户+数字”时保留原值,未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。 +私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话按最近一条有真实时间的收发消息倒序排列,无真实时间置末尾,不以保存时间或待发送请求的创建时间冒充;同时间按账号 ID、联系人 UID 固定排列,后端分页与前端合并使用相同规则。延迟返回的旧摘要不覆盖新摘要,刷新或加载更多不改变当前选中会话。会话列表与消息区联系人展示聊天客户端专用资料接口返回的真实昵称,支持普通用户与 AI 分身,不用普通主页接口代替、不用 UID 或会话标题占位;平台昵称本身为“用户+数字”时保留原值,未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。 评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号名称及对应作品的小封面;所属账号列与筛选下拉均不展示 UID,缺名仅显示前端“未命名账号”,评论者显示不变;作品使用本地封面,点击在新标签页打开抖音作品页面,缺图明确提示,不展示作品标题、不使用远程图片兜底。支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。 diff --git a/internal/creator/private_messages.go b/internal/creator/private_messages.go index cdd912c..299367f 100644 --- a/internal/creator/private_messages.go +++ b/internal/creator/private_messages.go @@ -452,7 +452,7 @@ func (s *Store) ListPrivateConversations(ctx context.Context, accountID string, if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM (SELECT m.account_id,m.peer_uid`+base+` GROUP BY m.account_id,m.peer_uid) c`, accountID).Scan(&out.Total); err != nil { return out, err } - rows, err := s.db.QueryContext(ctx, `SELECT a.account_id,a.name,m.peer_uid,COALESCE((array_agg(NULLIF(m.peer_name,'') ORDER BY m.created_at DESC) FILTER(WHERE m.peer_name<>''))[1],''),(array_agg(m.text ORDER BY COALESCE(m.message_at,m.created_at) DESC,m.created_at DESC,m.id DESC))[1],(array_agg(m.message_at ORDER BY COALESCE(m.message_at,m.created_at) DESC,m.created_at DESC,m.id DESC))[1],count(*)`+base+` GROUP BY a.id,a.account_id,a.name,m.peer_uid ORDER BY max(COALESCE(m.message_at,m.created_at)) DESC,a.id DESC,m.peer_uid LIMIT $2 OFFSET $3`, accountID, out.PageSize, (out.Page-1)*out.PageSize) + rows, err := s.db.QueryContext(ctx, `SELECT a.account_id,a.name,m.peer_uid,COALESCE((array_agg(NULLIF(m.peer_name,'') ORDER BY m.created_at DESC) FILTER(WHERE m.peer_name<>''))[1],''),(array_agg(m.text ORDER BY m.message_at DESC NULLS LAST,m.created_at DESC,m.id DESC))[1],max(m.message_at),count(*)`+base+` GROUP BY a.id,a.account_id,a.name,m.peer_uid ORDER BY max(m.message_at) DESC NULLS LAST,a.account_id COLLATE "C",m.peer_uid COLLATE "C" LIMIT $2 OFFSET $3`, accountID, out.PageSize, (out.Page-1)*out.PageSize) if err != nil { return out, err } diff --git a/internal/creator/private_messages_test.go b/internal/creator/private_messages_test.go index 719e88b..d85d832 100644 --- a/internal/creator/private_messages_test.go +++ b/internal/creator/private_messages_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "reflect" + "sort" "strings" "testing" "time" @@ -121,6 +123,68 @@ func TestPrivateInboxNicknameRefreshIsAccountScoped(t *testing.T) { } } +func TestPrivateConversationOrderUsesRealInboundAndOutboundTimes(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + var ids, generations []string + for _, name := range []string{"conversation-order-a", "conversation-order-b"} { + id := createIntegrationAccount(t, ctx, accounts, name) + profile, err := store.GetAccountProfile(ctx, id) + if err != nil { + t.Fatal(err) + } + if _, err := store.RecordVerifiedLoginResult(ctx, id, profile.PlatformAccountKey); err != nil { + t.Fatal(err) + } + listener, err := store.SetListenerEnabled(ctx, id, true) + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + generations = append(generations, listener.Generation) + } + older := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + newer := older.Add(time.Hour) + batch := &PrivateInbox{Messages: []PrivateInboxMessage{ + {PeerUID: "111", PeerName: "收到较早消息", ServerID: "101", Direction: "inbound", MessageType: "text", Text: "真实收到的消息", MessageAt: &older}, + {PeerUID: "222", PeerName: "发出最新消息", ServerID: "102", Direction: "outbound", MessageType: "text", Text: "真实发出的消息", MessageAt: &newer}, + {PeerUID: "333", PeerName: "收到最新消息", ServerID: "103", Direction: "inbound", MessageType: "text", Text: "真实收到的最新消息", MessageAt: &newer}, + }} + if err := store.SavePrivateInbox(ctx, ids[0], generations[0], batch); err != nil { + t.Fatal(err) + } + batch.Messages = []PrivateInboxMessage{{PeerUID: "222", ServerID: "104", Direction: "inbound", MessageType: "text", MessageAt: &newer}} + if err := store.SavePrivateInbox(ctx, ids[1], generations[1], batch); err != nil { + t.Fatal(err) + } + for i, peer := range []string{"111", "444"} { + if _, err := store.BeginPrivateMessage(ctx, PrivateMessageSendInput{RequestID: fmt.Sprintf("ordering-pending-%d", i), AccountID: ids[0], PeerUID: peer, Text: "尚无真实发送时间"}); err != nil { + t.Fatal(err) + } + } + first := []string{ids[0] + ":222", ids[0] + ":333", ids[1] + ":222"} + sort.Strings(first) + expected := append(first, ids[0]+":111", ids[0]+":444") + var actual []string + for page := 1; page <= 3; page++ { + result, err := store.ListPrivateConversations(ctx, "", page, 2) + if err != nil || result.Total != 5 { + t.Fatalf("page=%d result=%+v error=%v", page, result, err) + } + for _, c := range result.Data { + actual = append(actual, c.AccountID+":"+c.PeerUID) + if c.PeerUID == "111" && (c.LastMessageAt == nil || !c.LastMessageAt.Equal(older) || c.LastText != "真实收到的消息") { + t.Fatalf("pending attempt changed real conversation time: %+v", c) + } + if c.PeerUID == "444" && c.LastMessageAt != nil { + t.Fatalf("storage time presented as real time: %+v", c) + } + } + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("order=%v want=%v", actual, expected) + } +} + func TestPrivateListenerStateDoesNotDependOnNoticeProgress(t *testing.T) { store, accounts, ctx := openCreatorIntegrationStore(t) id := createIntegrationAccount(t, ctx, accounts, "independent-listener") diff --git a/web/src/utils/private-messages.ts b/web/src/utils/private-messages.ts index 3800296..8a0c04a 100644 --- a/web/src/utils/private-messages.ts +++ b/web/src/utils/private-messages.ts @@ -32,16 +32,31 @@ function timestamp(value: string): number { return Number.isFinite(time) ? time : 0; } +function conversationTime(conversation: Conversation): number { + const time = Date.parse(conversation.last_message_at || ''); + return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY; +} + export function mergeConversations(previous: Conversation[], incoming: Conversation[]): Conversation[] { const merged = new Map(previous.map((conversation) => [conversationKey(conversation), conversation])); const seen = new Set(); for (const conversation of incoming) { const key = conversationKey(conversation); - if (!seen.has(key)) merged.set(key, conversation); + const existing = merged.get(key); + const incomingTime = conversationTime(conversation); + const existingTime = existing ? conversationTime(existing) : Number.NEGATIVE_INFINITY; + // Late snapshots cannot roll back newer messages. For equal times, keep the + // first incoming page so overlapping later pages cannot replace its copy. + if (existing && (incomingTime < existingTime || (seen.has(key) && incomingTime === existingTime))) continue; + merged.set(key, conversation); seen.add(key); } - return [...merged.values()].sort((a, b) => timestamp(b.last_message_at) - timestamp(a.last_message_at) - || conversationKey(a).localeCompare(conversationKey(b))); + return [...merged.values()].sort((a, b) => { + const aTime = conversationTime(a), bTime = conversationTime(b); + if (aTime !== bTime) return aTime > bTime ? -1 : 1; + if (a.account_id !== b.account_id) return a.account_id < b.account_id ? -1 : 1; + return a.peer_uid === b.peer_uid ? 0 : a.peer_uid < b.peer_uid ? -1 : 1; + }); } export function mergeMessages(previous: PrivateMessage[], incoming: PrivateMessage[]): PrivateMessage[] { diff --git a/web/tests/private-messages-page.test.cjs b/web/tests/private-messages-page.test.cjs index dc747f2..f57dc0f 100644 --- a/web/tests/private-messages-page.test.cjs +++ b/web/tests/private-messages-page.test.cjs @@ -365,6 +365,28 @@ test('load more contacts merges and deduplicates; polling retains already loaded assert.ok(h.calls.some((p) => p.startsWith('/creator/private-messages/conversations?') && new URLSearchParams(p.split('?')[1]).get('page') === '2')); }); +test('stale contact pages cannot move a newer conversation down or change the selected peer', async (t) => { + let stage = 0; + const h = harness({ get(path) { + if (!path.startsWith('/creator/private-messages/conversations?')) return; + const page = Number(new URLSearchParams(path.split('?')[1]).get('page')); + const newest = { ...contacts[1], last_message_at: '2026-06-01T12:00:10Z', last_text: '最新收发消息' }; + const older = { ...contacts[1], last_message_at: '2026-06-01T12:00:00Z', last_text: '延迟返回的旧摘要' }; + return { data: page === 1 ? [contacts[0], stage === 1 ? older : newest] : [older], total: 3 }; + } }); t.after(h.dispose); + let tree = await h.flush(); + tree = await choose(h, 'a:100'); + assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); + await button(h, tree, '加载更多联系人').props.onClick(); tree = await h.flush(); + assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); + assert.match(text(conversations(h, tree).props.items[0].label), /最新收发消息/); + stage = 1; tree = await h.poll(); + assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); + assert.match(text(conversations(h, tree).props.items[0].label), /最新收发消息/); + assert.equal(conversations(h, tree).props.activeKey, 'a:100'); + assert.equal(h.sendCalls.length, 0); +}); + test('earlier messages are prepended, deduplicated and retained during polling', async (t) => { let polling = false; const h = harness({ get(path) { diff --git a/web/tests/private-messages.test.cjs b/web/tests/private-messages.test.cjs index 40e4344..accbd48 100644 --- a/web/tests/private-messages.test.cjs +++ b/web/tests/private-messages.test.cjs @@ -11,7 +11,7 @@ loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { }).outputText, file); const util = loaded.exports; -const contact = (account_id, peer_uid, time = '') => ({ account_id, peer_uid, account_name: '', peer_name: '', last_text: '', last_message_at: time, message_count: 0 }); +const contact = (account_id, peer_uid, time = '', last_text = '') => ({ account_id, peer_uid, account_name: '', peer_name: '', last_text, last_message_at: time, message_count: 0 }); const message = (id, time = '', overrides = {}) => ({ id, account_id: 'a', peer_uid: '100', direction: 'inbound', message_type: 'text', text: '', state: 'succeeded', error: '', message_at: time, created_at: '', ...overrides }); test('conversation and message API queries preserve pagination, source and encoded peers', () => { @@ -44,6 +44,30 @@ test('missing and invalid contact times stay behind known times, equal-time orde assert.deepEqual(util.mergeConversations([], rows).map(util.conversationKey), ['a:100', 'b:100', 'c:100']); }); +test('late conversation snapshots never roll back newer real message times or summaries', () => { + const fresh = contact('b', '100', '2026-06-01T12:00:10Z', '最新收发消息'); + const old = contact('b', '100', '2026-06-01T12:00:01Z', '旧分页摘要'); + const other = contact('a', '100', '2026-06-01T12:00:05Z'); + const result = util.mergeConversations([fresh, other], [old]); + assert.deepEqual(result.map(util.conversationKey), ['b:100', 'a:100']); + assert.equal(result[0].last_text, '最新收发消息'); + assert.equal(result[0].last_message_at, fresh.last_message_at); + const duplicates = util.mergeConversations([], [old, fresh, other]); + assert.equal(duplicates[0].last_text, '最新收发消息'); + assert.equal(duplicates.length, 2); + const incomplete = util.mergeConversations([fresh], [contact('b', '100', '', '尚无真实时间')]); + assert.equal(incomplete[0].last_message_at, fresh.last_message_at); + const renamed = util.mergeConversations([fresh], [{ ...fresh, peer_name: '实时昵称' }, { ...fresh, peer_name: '过期别名' }]); + assert.equal(renamed[0].peer_name, '实时昵称'); +}); + +test('unknown conversation times stay last even before the Unix epoch, and ties use stable compound keys', () => { + const rows = [contact('a', '2'), contact('z', '1', '1970-01-01T00:00:00Z'), contact('y', '1', '1969-12-31T23:59:59Z'), contact('a', '10'), contact('a0', '1'), contact('b', '1', 'invalid')]; + const expected = ['z:1', 'y:1', 'a:10', 'a:2', 'a0:1', 'b:1']; + assert.deepEqual(util.mergeConversations([], rows).map(util.conversationKey), expected); + assert.deepEqual(util.mergeConversations([], rows.slice().reverse()).map(util.conversationKey), expected); +}); + test('older message pages merge without duplicates, ascending times and updated send results', () => { const latest = [message('3', '2026-06-01T12:00:03Z'), message('2', '2026-06-01T12:00:02Z', { state: 'sending' })]; const updated = message('2', '2026-06-01T12:00:02Z', { state: 'succeeded' });