From 26fa98d9072d07e38a4039dba3f305a6948641d3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 7 Oct 2026 20:12:19 +0800 Subject: [PATCH] feat(events): refine aggregation ordering and presentation --- AGENTS.md | 4 +- .../creator/event_aggregation_order_test.go | 71 +++++++++++++++++++ internal/creator/event_listener.go | 2 +- web/src/pages/creator/events/index.tsx | 24 +++---- web/tests/event-aggregation-page.test.cjs | 46 ++++++++++-- web/tests/event-aggregation.test.cjs | 2 +- 6 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 internal/creator/event_aggregation_order_test.go diff --git a/AGENTS.md b/AGENTS.md index 95d5849..3724ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,9 +56,9 @@ 作品分析:左侧原“竞品分析”统一为“作品分析”,页面通过“竞品账号”“我的账号”两个 TAB 严格区分作品来源;各 TAB 独立保留筛选与分页。支持所属账号、发布时间和互动阈值筛选,以及发布时间、点赞、评论、分享、收藏、24h 点赞增量升降序;自有作品额外支持观看量,不展示“时间核验状态”筛选。默认最新发布在前,条件变化回到第一页,筛选与排序作用于全部已采集作品后再分页;缺失指标显示“—”,排序放末尾、阈值筛选不将其当作 0。未采集作品不参与分析。 -账号事件监听:仅支持抖音,自有账号默认关闭,在“我的账号”的“监听状态”列逐个开启,未登录环境不能开启。互动通知使用网页通知列表只读轮询,不再依赖私有 WebSocket SDK;每页 50 条,is_mark_read=0,不标记已读,原始 JSON 在 Python 解码以保留 64 位 ID。只记录点赞、评论、关注、转发,私信在独立收件箱同步与展示,不并入事件聚合,不自动互动。监听依赖已登录且运行中的浏览器;按账号固定页面并核验 UID,支持多标签页。分组读取位置、开启边界和事件在同一事务保存,提交成功后确认,重复投递去重;开关代次拒绝关闭期间的旧投递;首次开启、重新开启和服务重启均补齐平台仍可返回的全部历史和漏收记录,包括关闭期间通知,正常运行约每 5 分钟完整核对一次。开启时间只用于历史标记,不作为丢弃依据,补入保留平台原时间;不按头部位置或 100 页上限截断完整核对。开启设置不等于接收正常,状态应展示实际读取结果、最后成功时间及错误或可能遗漏。左侧“事件聚合”只展示当前已开启账号的已接收互动通知,按接收时间倒序,支持账号、类型、接收时间范围筛选和分页,每 5 秒刷新;关闭后保留历史但隐藏,重新开启后可查看。缺失发生时间不能用接收时间冒充;真实验收必须核对平台、数据库和页面同一通知,不把读取成功或平台合并后的重复互动当作新事件通过。 +账号事件监听:仅支持抖音,自有账号默认关闭,在“我的账号”的“监听状态”列逐个开启,未登录环境不能开启。互动通知使用网页通知列表只读轮询,不再依赖私有 WebSocket SDK;每页 50 条,is_mark_read=0,不标记已读,原始 JSON 在 Python 解码以保留 64 位 ID。只记录点赞、评论、关注、转发,私信在独立收件箱同步与展示,不并入事件聚合,不自动互动。监听依赖已登录且运行中的浏览器;按账号固定页面并核验 UID,支持多标签页。分组读取位置、开启边界和事件在同一事务保存,提交成功后确认,重复投递去重;开关代次拒绝关闭期间的旧投递;首次开启、重新开启和服务重启均补齐平台仍可返回的全部历史和漏收记录,包括关闭期间通知,正常运行约每 5 分钟完整核对一次。开启时间只用于历史标记,不作为丢弃依据,补入保留平台原时间;不按头部位置或 100 页上限截断完整核对。开启设置不等于接收正常,状态应展示实际读取结果、最后成功时间及错误或可能遗漏。左侧“事件聚合”只展示当前已开启账号的已接收互动通知,按发生时间倒序,缺少发生时间置末尾,同时间按事件 ID 倒序,支持账号、类型、接收时间范围筛选和分页,每 5 秒刷新;关闭后保留历史但隐藏,重新开启后可查看。缺失发生时间不能用接收时间冒充;真实验收必须核对平台、数据库和页面同一通知,不把读取成功或平台合并后的重复互动当作新事件通过。 -事件聚合资料:互动用户展示真实昵称与 UID,以平台返回的 secUID 链接主页,不使用数字 UID 猜测主页;发生时间在前、接收时间紧邻其后,所属账号及筛选选项仅显示昵称。对应作品展示 48px 小封面并链接抖音作品详情,图文使用 note 地址;缺封面保留作品入口,缺资料明确标示。通知中的作品封面复用 `<作者 UID>/<作品 ID>.<图片扩展名>` 本地缓存,不创建占位账号或作品,不回退展示远程图片。历史核对可补齐已保存事件的用户与作品资料,但只更新同一互动 UID、同一作品 ID 的资料,不改原事件时间、内容、历史标记或去重规则;封面下载失败单独记录和展示,不能阻断通知保存,也不能伪装成已缓存。 +事件聚合资料:互动用户仅展示真实昵称,不展示 UID,以平台返回的 secUID 链接主页,不使用数字 UID 猜测主页;列表仅展示发生时间,不展示接收时间列,但保留按接收时间计算的范围筛选。事件类型使用彩色 Tag 区分:点赞粉色、评论蓝色、关注紫色、转发橙色;来源使用彩色 Tag 区分:同步金色、通知青色,保留文字说明。“账号”列置于第一列,列表及筛选选项仅显示昵称。内容列限定在表格宽度内,长文本自动换行并直接完整展示,不截断、不折叠、不提供“展开”入口,不得撑宽页面。对应作品展示 48px 小封面并链接抖音作品详情,图文使用 note 地址;缺封面保留作品入口,缺资料明确标示。通知中的作品封面复用 `<作者 UID>/<作品 ID>.<图片扩展名>` 本地缓存,不创建占位账号或作品,不回退展示远程图片。历史核对可补齐已保存事件的用户与作品资料,但只更新同一互动 UID、同一作品 ID 的资料,不改原事件时间、内容、历史标记或去重规则;封面下载失败单独记录和展示,不能阻断通知保存,也不能伪装成已缓存。 私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话按最近一条有真实时间的收发消息倒序排列,无真实时间置末尾,不以保存时间或待发送请求的创建时间冒充;同时间按账号 ID、联系人 UID 固定排列,后端分页与前端合并使用相同规则。延迟返回的旧摘要不覆盖新摘要,刷新或加载更多不改变当前选中会话。会话列表与消息区联系人展示聊天客户端专用资料接口返回的真实昵称,支持普通用户与 AI 分身,不用普通主页接口代替、不用 UID 或会话标题占位;平台昵称本身为“用户+数字”时保留原值,未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。 diff --git a/internal/creator/event_aggregation_order_test.go b/internal/creator/event_aggregation_order_test.go new file mode 100644 index 0000000..ae7243b --- /dev/null +++ b/internal/creator/event_aggregation_order_test.go @@ -0,0 +1,71 @@ +package creator + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func TestEventAggregationOccurrenceOrderBeforePagination(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + id := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano())) + 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) + } + state, err := store.SetListenerEnabled(ctx, id, true) + if err != nil { + t.Fatal(err) + } + + // Old platform times with fresh receipt times keep the receipt-time filter independent. + newest := time.Now().UTC().Add(-30 * time.Hour) + tied := newest.Add(-10 * time.Hour) + oldest := tied.Add(-10 * time.Hour) + deliveries := []ListenerDelivery{ + {DeliveryID: "newest", Kind: "notice", Notice: &ListenerNotice{EventKey: "newest", EventType: "comment", PlatformEventAt: &newest}}, + {DeliveryID: "missing-first", Kind: "notice", Notice: &ListenerNotice{EventKey: "missing-first", EventType: "like"}}, + {DeliveryID: "oldest", Kind: "notice", Notice: &ListenerNotice{EventKey: "oldest", EventType: "like", PlatformEventAt: &oldest}}, + {DeliveryID: "tie-first", Kind: "notice", Notice: &ListenerNotice{EventKey: "tie-first", EventType: "comment", PlatformEventAt: &tied}}, + {DeliveryID: "tie-second", Kind: "notice", Notice: &ListenerNotice{EventKey: "tie-second", EventType: "like", PlatformEventAt: &tied}}, + {DeliveryID: "missing-second", Kind: "notice", Notice: &ListenerNotice{EventKey: "missing-second", EventType: "like"}}, + } + if err := store.RecordListenerDeliveries(ctx, id, state.Generation, deliveries); err != nil { + t.Fatal(err) + } + want := []string{"newest", "tie-second", "tie-first", "oldest", "missing-second", "missing-first"} + for pass := 0; pass < 2; pass++ { + var got []string + for pageNumber := 1; pageNumber <= 3; pageNumber++ { + page, err := store.ListAggregatedEventsPage(ctx, EventAggregationFilter{}, pageNumber, 2) + if err != nil { + t.Fatal(err) + } + if page.Total != len(want) || len(page.Data) != 2 || page.HasNext != (pageNumber < 3) { + t.Fatalf("page %d: %+v", pageNumber, page) + } + for _, event := range page.Data { + got = append(got, event.EventKey) + if event.EventKey == "missing-first" || event.EventKey == "missing-second" { + if event.PlatformEventAt != nil { + t.Fatalf("missing occurrence time was fabricated: %+v", event) + } + } + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("occurrence order across pages: got %v, want %v", got, want) + } + } + filtered, err := store.ListAggregatedEventsPage(ctx, EventAggregationFilter{AccountID: id, EventType: "comment", Hours: 1}, 1, 1) + if err != nil { + t.Fatal(err) + } + if filtered.Total != 2 || len(filtered.Data) != 1 || filtered.Data[0].EventKey != "newest" || !filtered.HasNext { + t.Fatalf("receipt-time filter and order before pagination: %+v", filtered) + } +} diff --git a/internal/creator/event_listener.go b/internal/creator/event_listener.go index 959cf59..f1fa398 100644 --- a/internal/creator/event_listener.go +++ b/internal/creator/event_listener.go @@ -358,7 +358,7 @@ func (s *Store) ListAggregatedEventsPage(ctx context.Context, filter EventAggreg e.interactor_uid, COALESCE(e.comment_id, ''), COALESCE(e.work_id, ''), e.message_type, e.message_text, e.platform_event_at, e.gateway_received_at, e.received_at, e.baseline, e.generation, e.interactor_name, e.interactor_sec_uid, e.work_author_uid, e.work_cover_url, e.work_url, e.work_cover_error`+from+` - ORDER BY e.received_at DESC, e.id DESC LIMIT $4 OFFSET $5`, append(args, pageSize, (page-1)*pageSize)...) + ORDER BY e.platform_event_at DESC NULLS LAST, e.id DESC LIMIT $4 OFFSET $5`, append(args, pageSize, (page-1)*pageSize)...) if err != nil { return result, err } diff --git a/web/src/pages/creator/events/index.tsx b/web/src/pages/creator/events/index.tsx index 5003a1d..6f0e02a 100644 --- a/web/src/pages/creator/events/index.tsx +++ b/web/src/pages/creator/events/index.tsx @@ -38,22 +38,22 @@ interface AccountEvent { platform_event_at?: string | null; } +const eventTypeColors: Record = { + like: 'magenta', comment: 'blue', follow: 'purple', repost: 'orange', +}; + const columns: ColumnsType = [ + { title: '账号', render: (_, event) => {event.account_name || '待登录'} }, { title: '发生时间', dataIndex: 'platform_event_at', render: (value?: string | null) => value ? dateTime(value) : '—' }, - { title: '接收时间', dataIndex: 'received_at', render: (value: string) => dateTime(value) }, - { title: '事件类型', dataIndex: 'event_type', render: (value: string) => {eventTypeLabel(value)} }, - { title: '所属账号', render: (_, event) => {event.account_name || '待登录'} }, + { title: '事件类型', dataIndex: 'event_type', render: (value: string) => {eventTypeLabel(value)} }, { title: '互动用户', render: (_, event) => { const href = eventUserProfileURL(event.interactor_sec_uid); const name = event.interactor_name || '昵称未获取'; - return - {href ? {name} - : {name}} - UID:{event.interactor_uid || '—'} - ; + return href ? {name} + : {name}; } }, - { title: '内容', render: (_, event) => ( - + { title: '内容', width: '30%', render: (_, event) => ( + {event.message_text || messageTypeLabel(event.message_type)} ) }, @@ -63,7 +63,7 @@ const columns: ColumnsType = [ : } />} ) : '—' }, - { title: '来源', dataIndex: 'baseline', render: (baseline: boolean) => baseline ? 历史记录 : 开启后通知 }, + { title: '来源', dataIndex: 'baseline', render: (baseline: boolean) => {baseline ? '同步' : '通知'} }, ]; export default function EventAggregationPage() { @@ -135,7 +135,7 @@ export default function EventAggregationPage() { 仅展示已开启监听账号的事件;停止监听的账号不在此列表展示,历史记录仍保留。时间范围按接收时间计算,仅记录事件,不执行自动互动。私信在独立收件箱展示。 {error ? : null} rowKey="id" columns={columns} dataSource={events} loading={pending} sticky={sticky} - scroll={{ x: 'max-content' }} + tableLayout="fixed" locale={{ emptyText: '尚未收到事件。请在“我的账号”开启监听,并保持账号浏览器运行。' }} pagination={{ ...tablePagination(), current: pagination.page, pageSize: pagination.pageSize, total, onChange: (page, pageSize) => setPagination({ page: pageSize !== pagination.pageSize ? 1 : page, pageSize }) }} /> diff --git a/web/tests/event-aggregation-page.test.cjs b/web/tests/event-aggregation-page.test.cjs index b3af4e9..9ec9025 100644 --- a/web/tests/event-aggregation-page.test.cjs +++ b/web/tests/event-aggregation-page.test.cjs @@ -105,15 +105,51 @@ test('event contents distinguish missing data, non-text messages and baseline hi assert.equal(missing.props.href,'https://www.douyin.com/video/123'); assert.equal(all(missing,n=>n.type===h.antd.Avatar)[0].props.size,48); assert.equal(all(missing,n=>n.type===h.antd.Tooltip)[0].props.title,'下载失败'); - assert.equal(text(column('来源').render(true)),'历史记录');assert.equal(text(column('来源').render(false)),'开启后通知'); + assert.equal(text(column('来源').render(true)),'同步');assert.equal(text(column('来源').render(false)),'通知'); const user=column('互动用户').render(null,{interactor_uid:'22',interactor_name:'用户',interactor_sec_uid:'MS4wLjABAAAAprofile'}); const profile=all(user,n=>n.type===h.antd.Typography.Link)[0]; assert.equal(profile.props.href,'https://www.douyin.com/user/MS4wLjABAAAAprofile'); - assert.equal(profile.props.target,'_blank');assert.match(text(user),/用户/);assert.match(text(user),/UID:22/); + assert.equal(profile.props.target,'_blank');assert.equal(text(user),'用户'); const unknown=column('互动用户').render(null,{interactor_uid:'22',interactor_name:'',interactor_sec_uid:''}); - assert.equal(all(unknown,n=>n.type===h.antd.Typography.Link).length,0);assert.match(text(unknown),/昵称未获取/); - assert.equal(text(column('所属账号').render(null,{account_name:'账号',account_uid:'1'})),'账号'); - assert.deepEqual(cols.slice(0,2).map(c=>c.title),['发生时间','接收时间']); + assert.equal(all(unknown,n=>n.type===h.antd.Typography.Link).length,0);assert.equal(text(unknown),'昵称未获取'); + assert.equal(text(column('账号').render(null,{account_name:'账号',account_uid:'1'})),'账号'); + assert.deepEqual(cols.map(c=>c.title),['账号','发生时间','事件类型','互动用户','内容','对应作品','来源']); + assert.equal(cols.some(c=>c.title==='接收时间'||c.dataIndex==='received_at'),false); +}); + +test('event types and sources have distinct color tags with readable labels',async t=>{ + const h=harness();t.after(()=>h.dispose());h.render();await h.effects(); + const columns=table(h,h.render()).props.columns; + const type=columns.find(column=>column.title==='事件类型'); + const colors=[]; + for(const [value,label,color] of [['like','点赞','magenta'],['comment','评论','blue'],['follow','关注','purple'],['repost','转发','orange']]){ + const tag=type.render(value); + assert.equal(tag.type,h.antd.Tag);assert.equal(text(tag),label);assert.equal(tag.props.color,color); + colors.push(tag.props.color); + } + const source=columns.find(column=>column.title==='来源'); + for(const [baseline,label,color] of [[true,'同步','gold'],[false,'通知','cyan']]){ + const tag=source.render(baseline); + assert.equal(tag.type,h.antd.Tag);assert.equal(text(tag),label);assert.equal(tag.props.color,color); + colors.push(tag.props.color); + } + assert.equal(new Set(colors).size,6); + assert.ok(select(h,h.render(),'接收时间范围')); +}); + +test('long content has a bounded column and displays all text without expansion',async t=>{ + const h=harness();t.after(()=>h.dispose());h.render();await h.effects(); + const eventTable=table(h,h.render()); + assert.equal(eventTable.props.tableLayout,'fixed'); + assert.equal(eventTable.props.scroll?.x,undefined); + const content=eventTable.props.columns.find(column=>column.title==='内容'); + assert.equal(content.width,'30%'); + for(const message of ['长评论内容'.repeat(200),'a'.repeat(2000),`https://example.com/${'path'.repeat(500)}`]){ + const paragraph=content.render(null,{message_text:message,message_type:'text'}); + assert.equal(paragraph.type,h.antd.Typography.Paragraph); + assert.equal(text(paragraph),message); + assert.equal(paragraph.props.ellipsis,undefined); + } }); test('read failures are visible and retries do not fake an empty success',async t=>{ diff --git a/web/tests/event-aggregation.test.cjs b/web/tests/event-aggregation.test.cjs index 3089ccd..60f2d83 100644 --- a/web/tests/event-aggregation.test.cjs +++ b/web/tests/event-aggregation.test.cjs @@ -71,5 +71,5 @@ test('event aggregation is listed in the left menu and keeps source scope explic assert.match(source,/停止监听的账号不在此列表展示/); assert.doesNotMatch(source,/label: '私信'/); assert.match(source,/私信在独立收件箱展示/); - assert.match(source,/开启后通知/); + assert.match(source,/baseline \? '同步' : '通知'/); });