feat: add account events and private message management
douyin-release-gate / verify (push) Failing after 18m54s

Add Douyin notification polling, event details, and manual multi-account private messaging.

Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
This commit is contained in:
2026-10-07 10:23:05 +08:00
parent 0b4a7e12a1
commit e50bed9ad8
110 changed files with 10456 additions and 1446 deletions
@@ -0,0 +1,41 @@
export type CollectionCheckpoint = {
status?: string;
last_completed_at?: string | null;
last_error?: string | null;
};
const labels: Record<string, { text: string; color: string }> = {
idle: { text: '待采集', color: 'default' },
pending: { text: '待采集', color: 'default' },
succeeded: { text: '采集完成', color: 'success' },
running: { text: '采集运行中', color: 'processing' },
failed: { text: '采集失败', color: 'error' },
blocked: { text: '采集受阻', color: 'warning' },
};
export function collectionStatusPresentation(checkpoint?: CollectionCheckpoint | null) {
const reason = checkpoint?.last_error || '';
if (reason.includes('browser is awaiting user login')) {
return {
text: '需要登录',
color: 'warning',
reason: '浏览器需要登录,请登录并同步账号后重试。',
};
}
const state = checkpoint?.status ? labels[checkpoint.status] : undefined;
return { ...(state || { text: '状态未知', color: 'default' }), reason };
}
const accountStatusPriority = ['需要登录', '采集失败', '采集受阻', '状态未知', '采集运行中', '待采集', '采集完成'];
export function accountCollectionStatusPresentation(
works?: CollectionCheckpoint | null,
comments?: CollectionCheckpoint | null,
) {
const states = [works, comments].map(collectionStatusPresentation);
const primary = states.reduce((current, candidate) =>
accountStatusPriority.indexOf(current.text) <= accountStatusPriority.indexOf(candidate.text) ? current : candidate,
);
const reasons = [...new Set(states.map(state => state.reason).filter(Boolean))];
return { ...primary, reason: reasons.join(';') };
}
+45
View File
@@ -0,0 +1,45 @@
export const eventTypeLabels: Record<string, string> = {
like: '点赞', comment: '评论', follow: '关注', repost: '转发',
};
export function eventTypeLabel(type: string): string {
return eventTypeLabels[type] ?? type;
}
export function listenerStatusLabel(status: string): string {
const labels: Record<string, string> = {
stopped: '已关闭', starting: '启动中', ready: '接收正常', gap: '可能有遗漏', stopping: '关闭中', error: '异常',
};
return labels[status] ?? status;
}
export function messageTypeLabel(type: string): string {
const labels: Record<string, string> = {
image: '图片消息', voice: '语音消息', video: '视频消息', sticker: '表情消息', non_text: '非文本消息', text: '—',
};
return labels[type] ?? type;
}
export function eventUserProfileURL(secUID?: string): string | undefined {
return secUID && /^[A-Za-z0-9_-]{1,256}$/.test(secUID)
? `https://www.douyin.com/user/${encodeURIComponent(secUID)}` : undefined;
}
export function eventWorkURL(workID: string, provided?: string): string | undefined {
if (!/^[1-9][0-9]{0,29}$/.test(workID)) return undefined;
if (provided) return new RegExp(`^https://www\\.douyin\\.com/(video|note)/${workID}$`).test(provided) ? provided : undefined;
return `https://www.douyin.com/video/${workID}`;
}
export interface EventFilters {
accountId?: string;
eventType?: string;
hours: number;
}
export function eventQuery(filters: EventFilters, page: number, pageSize: number): string {
const query = new URLSearchParams({ page: String(page), page_size: String(pageSize), hours: String(filters.hours) });
if (filters.accountId) query.set('account_id', filters.accountId);
if (filters.eventType) query.set('event_type', filters.eventType);
return query.toString();
}
+10 -6
View File
@@ -2,7 +2,9 @@
export function dateTime(value?: string | number | null): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
if (Number.isNaN(date.getTime())) return String(value);
const twoDigits = (part: number) => String(part).padStart(2, '0');
return `${String(date.getFullYear()).slice(-2)}/${twoDigits(date.getMonth() + 1)}/${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}`;
}
// 优先展示后端具体 reason;409/503 冲突类错误即使无 hint 也直接透出后端
@@ -58,7 +60,7 @@ export function extractShareURL(value?: string): string {
return candidates?.find((candidate) => platformForShareURL(candidate)) || candidates?.[0] || '';
}
// 账号可恢复性判定(对齐归档版 accountReadiness):绑定、出口健康、账号暂停三层。
// 分别判定采集调度、环境启动和账号恢复:未启动阻止调度,但不能阻止启动。
export const readinessReasonText: Record<string, string> = {
account_paused: '账号已暂停',
binding_missing: '未绑定运行环境',
@@ -75,28 +77,30 @@ export interface AccountReadiness {
label: string;
reason: string;
canResume: boolean;
canStart: boolean;
ready: boolean;
}
export function accountReadiness(account: any, binding: any, bindingError: any = null): AccountReadiness {
if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false };
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false };
if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, canStart: false, ready: false };
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, canStart: false, ready: false };
let blocked = '';
if (binding.runtime_cleanup_pending) {
blocked = 'runtime_stop_pending';
} else if (binding.network_exit_id && binding.network_exit_health !== 'healthy') {
blocked = 'network_exit_unhealthy';
}
if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, ready: false };
if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, canStart: false, ready: false };
if (account.runtime_status === 'paused') {
const stopped = !binding.runtime_id;
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false };
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, canStart: false, ready: false };
}
const schedule = binding.schedule_block_reason || '';
return {
label: schedule ? readinessReasonText[schedule] || `不可调度:${schedule}` : '可调度',
reason: schedule,
canResume: false,
canStart: !schedule || schedule === 'runtime_missing',
ready: !schedule,
};
}
+6
View File
@@ -5,6 +5,8 @@ import {
EyeOutlined,
LineChartOutlined,
CommentOutlined,
BellOutlined,
MessageOutlined,
SettingOutlined,
ProfileOutlined,
ClusterOutlined,
@@ -23,6 +25,8 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
{ test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' },
{ test: (p) => p === '/creator/comments', title: '评论聚合', subtitle: '分别查看我的作品与竞品作品的已采集评论,按所属账号和评论发布时间筛选。' },
{ test: (p) => p === '/creator/events', title: '事件聚合', subtitle: '查看已开启账号的点赞、评论、关注与转发通知;私信在独立收件箱展示。' },
{ test: (p) => p === '/creator/messages', title: '私信管理', subtitle: '查看并回复已监听账号的文字私信会话。' },
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
@@ -42,6 +46,8 @@ export const menu: MenuDataItem[] = [
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
{ path: '/creator/comments', name: '评论聚合', icon: <CommentOutlined /> },
{ path: '/creator/events', name: '事件聚合', icon: <BellOutlined /> },
{ path: '/creator/messages', name: '私信管理', icon: <MessageOutlined /> },
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
],
},
+126
View File
@@ -0,0 +1,126 @@
export interface Conversation {
account_id: string;
account_name: string;
peer_uid: string;
peer_name: string;
last_text: string;
last_message_at: string;
message_count: number;
}
export interface PrivateMessage {
id: string;
account_id: string;
peer_uid: string;
direction: string;
message_type: string;
text: string;
state: string;
error: string;
message_at: string;
created_at: string;
}
export type ChatDrafts = Record<string, { text: string; version: number }>;
export function conversationKey(conversation: Pick<Conversation, 'account_id' | 'peer_uid'>): string {
return `${conversation.account_id}:${conversation.peer_uid}`;
}
function timestamp(value: string): number {
const time = Date.parse(value);
return Number.isFinite(time) ? time : 0;
}
export function mergeConversations(previous: Conversation[], incoming: Conversation[]): Conversation[] {
const merged = new Map(previous.map((conversation) => [conversationKey(conversation), conversation]));
const seen = new Set<string>();
for (const conversation of incoming) {
const key = conversationKey(conversation);
if (!seen.has(key)) 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)));
}
export function mergeMessages(previous: PrivateMessage[], incoming: PrivateMessage[]): PrivateMessage[] {
const merged = new Map(previous.map((message) => [message.id, message]));
const seen = new Set<string>();
for (const message of incoming) {
if (!seen.has(message.id)) merged.set(message.id, message);
seen.add(message.id);
}
return [...merged.values()].sort((a, b) => timestamp(a.message_at || a.created_at) - timestamp(b.message_at || b.created_at)
|| a.id.localeCompare(b.id, undefined, { numeric: true }));
}
export function updateDraft(drafts: ChatDrafts, key: string, text: string): ChatDrafts {
return { ...drafts, [key]: { text, version: (drafts[key]?.version ?? 0) + 1 } };
}
export function clearSentDraft(drafts: ChatDrafts, key: string, version: number): ChatDrafts {
if (!drafts[key] || drafts[key].version !== version) return drafts;
return updateDraft(drafts, key, '');
}
export interface MessageFilters {
accountId?: string;
}
export function conversationQuery(filters: MessageFilters, page: number, pageSize: number): string {
const query = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filters.accountId) query.set('account_id', filters.accountId);
return query.toString();
}
export function messagesQuery(accountId: string, peerUid: string, page: number, pageSize: number): string {
const query = new URLSearchParams({
account_id: accountId,
peer_uid: peerUid,
page: String(page),
page_size: String(pageSize),
});
return query.toString();
}
export const messageDirectionLabels: Record<string, string> = {
inbound: '收到',
outbound: '发出',
};
export function messageDirectionLabel(direction: string): string {
return messageDirectionLabels[direction] ?? direction;
}
export const messageStateLabels: Record<string, string> = {
succeeded: '已发送',
sending: '发送中',
failed: '发送失败',
unknown: '结果未确认',
};
export const messageStateTones: Record<string, string> = {
succeeded: 'success',
sending: 'processing',
failed: 'error',
unknown: 'warning',
};
export function messageStateLabel(state: string): string {
return messageStateLabels[state] ?? state;
}
export function messageStateTone(state: string): string {
return messageStateTones[state] ?? 'default';
}
export const messageTypeLabels: Record<string, string> = {
text: '文字',
image: '图片',
unknown: '未知类型',
};
export function messageTypeLabel(type: string): string {
return messageTypeLabels[type] ?? type;
}