feat: push Douyin events through shared WebSocket and SSE
douyin-release-gate / verify (push) Failing after 18m0s
douyin-release-gate / verify (push) Failing after 18m0s
This commit is contained in:
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Alert, Avatar, Button, Card, Flex, Select, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { creatorGet } from '@/services/api';
|
||||
import { creatorGet, creatorSubscribe } from '@/services/api';
|
||||
import { usePageState } from '@/hooks/usePageState';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
import { tablePagination, useTableSticky } from '@/utils/table';
|
||||
@@ -75,17 +75,25 @@ export default function EventAggregationPage() {
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [streamStatus, setStreamStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const sticky = useTableSticky();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let busy = false;
|
||||
let queued = false;
|
||||
const controller = new AbortController();
|
||||
setStreamStatus('connecting');
|
||||
const query = eventQuery(filters, pagination.page, pagination.pageSize);
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setEvents([]);
|
||||
setTotal(0);
|
||||
async function load() {
|
||||
if (!active) return;
|
||||
if (busy) { queued = true; return; }
|
||||
busy = true;
|
||||
try {
|
||||
const [page, profiles] = await Promise.all([
|
||||
creatorGet(`/creator/events?${query}`), creatorGet('/creator/accounts'),
|
||||
@@ -100,14 +108,32 @@ export default function EventAggregationPage() {
|
||||
console.error('读取账号事件失败', { query, error: cause });
|
||||
if (active) setError(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
if (active) {
|
||||
setPending(false);
|
||||
timer = setTimeout(load, 5000);
|
||||
if (queued) { queued = false; void load(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
async function connect() {
|
||||
try {
|
||||
await creatorSubscribe('/creator/updates', () => { if (active) void load(); }, controller.signal,
|
||||
(status) => {
|
||||
if (!active) return;
|
||||
setStreamStatus(status);
|
||||
if (status === 'connected') void load();
|
||||
});
|
||||
} catch (cause) {
|
||||
if (active) console.error('事件实时更新连接失败', { error: cause });
|
||||
}
|
||||
if (active) {
|
||||
setStreamStatus('disconnected');
|
||||
timer = setTimeout(connect, 2000);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
void connect();
|
||||
return () => { active = false; controller.abort(); clearTimeout(timer); };
|
||||
}, [filters.accountId, filters.eventType, filters.hours, pagination.page, pagination.pageSize, revision]);
|
||||
|
||||
function updateFilters(next: Partial<EventFilters>) {
|
||||
@@ -118,7 +144,7 @@ export default function EventAggregationPage() {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {total} 条事件 · 每 5 秒刷新</Typography.Text>
|
||||
<Typography.Text type="secondary">共 {total} 条事件 · {streamStatus === 'connected' ? '实时更新' : streamStatus === 'connecting' ? '正在连接实时更新' : '实时更新已断开'}</Typography.Text>
|
||||
<Button icon={<ReloadOutlined />} disabled={pending} onClick={() => setRevision((value) => value + 1)}>刷新</Button>
|
||||
</Flex>
|
||||
<Flex wrap gap="middle" align="center" style={{ marginBottom: 16 }}>
|
||||
@@ -133,6 +159,7 @@ export default function EventAggregationPage() {
|
||||
{ value: 24, label: '最近 1 天' }, { value: 72, label: '最近 3 天' }, { value: 168, label: '最近 7 天' }]} />
|
||||
</Flex>
|
||||
<Typography.Paragraph type="secondary">仅展示已开启监听账号的事件;停止监听的账号不在此列表展示,历史记录仍保留。时间范围按接收时间计算,仅记录事件,不执行自动互动。私信在独立收件箱展示。</Typography.Paragraph>
|
||||
{streamStatus === 'disconnected' ? <Alert type="warning" showIcon title="实时更新连接已断开" description="正在重新连接;当前列表保留,也可手动刷新。" style={{ marginBottom: 16 }} /> : null}
|
||||
{error ? <Alert type="error" showIcon title="事件读取失败" description={conflictMessage(error, '请检查服务状态后重试。')} style={{ marginBottom: 16 }} /> : null}
|
||||
<Table<AccountEvent> rowKey="id" columns={columns} dataSource={events} loading={pending} sticky={sticky}
|
||||
tableLayout="fixed"
|
||||
|
||||
@@ -9,7 +9,7 @@ const React = require('react');
|
||||
|
||||
function transpile(file) { return ts.transpileModule(readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020}}).outputText; }
|
||||
function harness(options={}) {
|
||||
const states=[],effects=[],queued=[],calls=[];
|
||||
const states=[],effects=[],queued=[],calls=[],streams=[];
|
||||
let cursor=0;
|
||||
const antd={};
|
||||
for(const name of ['Alert','Avatar','Button','Card','Flex','Select','Table','Tag','Tooltip']){antd[name]=()=>null;antd[name].displayName=name;}
|
||||
@@ -29,11 +29,14 @@ function harness(options={}) {
|
||||
if(id==='@/hooks/usePageState')return loadPageState(loaded.require('react'),{storage:options.storage});
|
||||
if(id==='@/utils/table')return{useTableSticky:()=>false,tablePagination:()=>({})};
|
||||
if(id==='@/utils/helpers')return{dateTime:value=>`time:${value}`,conflictMessage:(cause,fallback)=>cause.message||fallback};
|
||||
if(id==='@/services/api')return{creatorGet:async path=>{calls.push(path);if(options.get)return options.get(path);if(path==='/creator/accounts')return options.accounts||[];return options.page||{data:[],total:0};}};
|
||||
if(id==='@/services/api')return{creatorGet:async path=>{calls.push(path);if(options.get)return options.get(path);if(path==='/creator/accounts')return options.accounts||[];return options.page||{data:[],total:0};},creatorSubscribe:(path,onMessage,signal,onStatus)=>{
|
||||
streams.push({path,onMessage,signal,onStatus});onStatus?.('connected');
|
||||
return new Promise(resolve=>signal.addEventListener('abort',resolve,{once:true}));
|
||||
}};
|
||||
return require(id);
|
||||
};
|
||||
loaded._compile(transpile(file),file);
|
||||
return{antd,calls,render(){cursor=0;return loaded.exports.default();},async effects(){while(queued.length)queued.shift()();for(let i=0;i<8;i++)await Promise.resolve();},dispose(){for(const effect of effects)effect?.cleanup?.();}};
|
||||
return{antd,calls,streams,push(){streams.at(-1).onMessage('data: {}');},render(){cursor=0;return loaded.exports.default();},async effects(){while(queued.length)queued.shift()();for(let i=0;i<8;i++)await Promise.resolve();},dispose(){for(const effect of effects)effect?.cleanup?.();}};
|
||||
}
|
||||
function all(node,predicate){const result=[];function visit(value){if(Array.isArray(value))return value.forEach(visit);if(!React.isValidElement(value))return;if(predicate(value))result.push(value);visit(value.props.children);}visit(node);return result;}
|
||||
function text(node){if(node==null)return'';if(typeof node==='string'||typeof node==='number')return String(node);if(Array.isArray(node))return node.map(text).join('');return React.isValidElement(node)?text(node.props.children):'';}
|
||||
@@ -85,7 +88,17 @@ test('only enabled accounts are offered, event filters are server-wide and chang
|
||||
table(h,tree).props.pagination.onChange(3,50);h.render();await h.effects();tree=h.render();
|
||||
assert.equal(table(h,tree).props.pagination.current,1);assert.equal(table(h,tree).props.pagination.pageSize,50);
|
||||
all(tree,node=>node.type===h.antd.Button)[0].props.onClick();h.render();await h.effects();
|
||||
assert.equal(h.calls.filter(p=>p.startsWith('/creator/events')).length,7);
|
||||
assert.ok(h.calls.filter(p=>p.startsWith('/creator/events')).length>=7);
|
||||
assert.ok(h.streams.every(s=>s.path==='/creator/updates'));
|
||||
const before=h.calls.length;h.push();await h.effects();assert.ok(h.calls.length>before);
|
||||
assert.equal(table(h,h.render()).props.pagination.current,1);
|
||||
});
|
||||
|
||||
test('stream disconnect is visible and cleanup aborts the subscription',async t=>{
|
||||
const h=harness();t.after(()=>h.dispose());h.render();await h.effects();
|
||||
h.streams.at(-1).onStatus('disconnected');
|
||||
assert.ok(all(h.render(),n=>n.type===h.antd.Alert&&n.props.title==='实时更新连接已断开').length);
|
||||
const signal=h.streams.at(-1).signal;h.dispose();assert.equal(signal.aborted,true);
|
||||
});
|
||||
|
||||
test('event contents distinguish missing data, non-text messages and baseline history',async t=>{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const test=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
|
||||
const source=fs.readFileSync('../browser_gateway/platform/notice_signal.py','utf8');
|
||||
const install=source.match(/INSTALL = r"""([\s\S]*?)"""/)[1];const wait=source.match(/WAIT = r"""([\s\S]*?)"""/)[1];
|
||||
function browser(){
|
||||
const listeners=new Map(),timers=new Map();let counter=0;
|
||||
const f={_options:{deviceID:'123'},OPEN:1,readyState:1,addEventListener(k,fn){if(!listeners.has(k))listeners.set(k,new Set());listeners.get(k).add(fn);},removeEventListener(k,fn){listeners.get(k)?.delete(fn);}};
|
||||
const C={frontierInstance:f};const modules={sdk:{NoticeFrontier:C},codec:{decodedFrame:data=>({service:20313,payload:data})}};
|
||||
const req=id=>modules[id];req.m={sdk:function(){/*NOTICE_PUSH_EVENT_NAMES:function*/},codec:function(){/* .decodedFrame= .encodeFrame= */}};
|
||||
const chunks=[];chunks.push=function(v){v[2](req);return Array.prototype.push.call(this,v)};
|
||||
const window={webpackChunkdouyin_web:chunks};
|
||||
const context=vm.createContext({window,location:{origin:'https://www.douyin.com'},Uint8Array,TextDecoder,JSON,Date,Error,
|
||||
setTimeout(fn){const id=++counter;timers.set(id,fn);return id;},clearTimeout(id){timers.delete(id);}});
|
||||
const evaluate=s=>vm.runInContext(s.replaceAll('KEY','"bridge"').replaceAll('UID','"123"').replaceAll('ALIAS','"account"'),context);
|
||||
return{window,f,C,timers,evaluate,emit(k,value){for(const fn of listeners.get(k)||[])fn(value)},listeners};
|
||||
}
|
||||
test('native notification wakes a pending receiver without waiting for heartbeat',async()=>{
|
||||
const b=browser();assert.equal(JSON.parse(await b.evaluate(install)).connected,true);
|
||||
const pending=b.evaluate(wait);assert.equal(b.timers.size,1);
|
||||
const payload='{"notices":[{"notice_id_str":"9007199254740993123","effect_groups":[700]}]}';
|
||||
b.emit('message',{data:Buffer.from(payload)});
|
||||
const result=JSON.parse(await pending);assert.equal(result[0].payload,payload);assert.equal(b.timers.size,0);
|
||||
});
|
||||
test('connection replacement and identity change fail explicitly',async()=>{
|
||||
const b=browser();await b.evaluate(install);b.C.frontierInstance={};await assert.rejects(b.evaluate(wait),/identity or page changed/);
|
||||
const c=browser();await c.evaluate(install);c.f._options.deviceID='456';await assert.rejects(c.evaluate(wait),/identity or page changed/);
|
||||
});
|
||||
test('signal overload reports an error rather than silently dropping notifications',async()=>{
|
||||
const b=browser();await b.evaluate(install);
|
||||
for(let i=0;i<1001;i++)b.emit('message',{data:Buffer.from('{}')});
|
||||
await assert.rejects(b.evaluate(wait),/queue full/);
|
||||
});
|
||||
test('reinstall removes stale callbacks instead of accumulating page listeners',async()=>{
|
||||
const b=browser();await b.evaluate(install);await b.evaluate(install);
|
||||
assert.equal(b.listeners.get('message').size,1);assert.equal(b.listeners.get('close').size,1);
|
||||
});
|
||||
test('disposal removes page listeners and wakes an idle receiver',async()=>{
|
||||
const b=browser();await b.evaluate(install);const pending=b.evaluate(wait);b.window.bridge.dispose();await pending;
|
||||
assert.equal(b.listeners.get('message').size,0);assert.equal(b.listeners.get('close').size,0);
|
||||
});
|
||||
Reference in New Issue
Block a user