Merge branch 'feature/monitoring-account-works'——监控账号详情页承载作品列表与统计 Drawer,blocked 同步自动重试
douyin-release-gate / verify (push) Failing after 3m11s

This commit is contained in:
2026-09-27 13:23:00 +08:00
7 changed files with 392 additions and 15 deletions
+25 -3
View File
@@ -1778,8 +1778,20 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
if !claimed {
return creator.CollectionReport{}, creator.ErrConflict
}
// blocked(环境暂不可用)也必须排下一次重试:否则 next_sync_at 置空后定时获取对该账号永久失效。
nextRetry := func() *time.Time {
nextBase := now
if competitor.NextSyncAt != nil {
nextBase = competitor.NextSyncAt.UTC()
}
next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
if next.IsZero() {
return nil
}
return &next
}
blocked := func(blockErr error) (creator.CollectionReport, error) {
markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nil)
markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nextRetry())
return creator.CollectionReport{}, errors.Join(blockErr, markErr)
}
if competitor.Platform != creator.PlatformDouyin {
@@ -1834,7 +1846,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
status := "failed"
if errors.Is(collectErr, creator.ErrUnavailable) || errors.Is(collectErr, creator.ErrConflict) {
status, next = "blocked", time.Time{}
status = "blocked"
}
var nextAt *time.Time
if !next.IsZero() {
@@ -1928,7 +1940,17 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
continue
}
if claimed {
if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nil); markErr != nil {
// blocked 也排下一次重试(按新作品间隔),避免无采集账号时永久卡死。
nextBase := now
if competitor.NextSyncAt != nil {
nextBase = competitor.NextSyncAt.UTC()
}
next := creator.NextFixedRun(nextBase, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
var nextAt *time.Time
if !next.IsZero() {
nextAt = &next
}
if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nextAt); markErr != nil {
logrus.WithError(markErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync block update failed")
}
}
+15
View File
@@ -289,6 +289,21 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) {
if err := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "idle", "", "", nil); err != nil {
t.Fatal(err)
}
// blocked 同步也必须排下一次重试:否则 next_sync_at 置空后定时获取对该账号永久失效。
blockedToken, blockedClaimed, err := store.ClaimCompetitorSync(ctx, competitor.ID, true, dueNow)
if err != nil || !blockedClaimed {
t.Fatalf("claim competitor sync for blocked: err=%v claimed=%v", err, blockedClaimed)
}
blockedNext := dueNow.Add(2 * time.Hour)
if err := store.MarkCompetitorSync(ctx, competitor.ID, blockedToken, "blocked", "", "runtime unavailable", &blockedNext); err != nil {
t.Fatal(err)
}
if due, err := store.ListDueCompetitors(ctx, blockedNext); err != nil || len(due) != 1 || due[0].ID != competitor.ID {
t.Fatalf("blocked competitor must reschedule: due=%+v err=%v", due, err)
}
if due, err := store.ListDueCompetitors(ctx, dueNow); err != nil || len(due) != 0 {
t.Fatalf("blocked competitor must not fire before retry time: due=%+v err=%v", due, err)
}
work, inserted, err = store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: workKey, SourceType: SourceCompetitor, SourceID: competitor.ID, Title: "", Body: "", PublishedAt: nil, Likes: nil, CommentsCount: nil, Shares: nil}, now)
if err != nil || inserted || len(work.Sources) != 2 {
t.Fatalf("work source association was not retained: work=%+v inserted=%v err=%v", work, inserted, err)
@@ -22,8 +22,8 @@ import {
message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import { getList, remove, creatorAction, creatorUpdate } from '@/services/api';
import { conflictMessage, dateTime, isCollectionAccount, platformLabel } from '@/utils/helpers';
import { getList, remove, creatorAction, creatorUpdate, syncCompetitorAuto } from '@/services/api';
import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
interface CompetitorRow {
id: string;
@@ -88,14 +88,7 @@ export default function MonitoringAccountList() {
async function syncCompetitor(competitor: CompetitorRow) {
setActionBusy(competitor.id);
try {
const result = await getList({ resource: 'creator-accounts' });
const account = (result.data ?? []).find(
(candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
);
if (!account) {
throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
}
await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await load();
} catch (actionError) {
@@ -177,7 +170,10 @@ export default function MonitoringAccountList() {
itemRender={(row) => {
const meta = syncStatusMeta[row.sync_status ?? ''] || { label: row.sync_status || '—' };
return (
<div style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)' }}>
<div
style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)', cursor: 'pointer' }}
onClick={() => history.push(`/accounts/monitoring/${row.id}`)}
>
<Flex justify="space-between" align="flex-start" gap={16}>
<Flex align="flex-start" gap={12}>
<Avatar size={44} src={row.avatar_url || undefined}>
@@ -197,7 +193,7 @@ export default function MonitoringAccountList() {
</Typography.Paragraph>
</div>
</Flex>
<Space wrap>
<Space wrap onClick={(e) => e.stopPropagation()}>
<Button size="small" onClick={() => openTags(row)}>
编辑标签
</Button>
@@ -0,0 +1,330 @@
// 监控账号详情:形态对齐账号详情页(页头槽 + 卡片流)——
// 页头 info 为账号标识(头像 + 昵称 + 同步状态 + 平台),actions 为返回/同步/暂停恢复;
// 主体两张 Card:账号画像(Descriptions)与作品列表(Row/Col card 网格,分页)。
// 点击作品弹出 Drawer 展示统计信息:作品详情 + 指标采集记录表。
// 作品列表走 GET /creator/works?source_id=<competitor_id>,定时采集由后端调度器维护。
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import {
Alert,
Avatar,
Button,
Card,
Col,
Descriptions,
Drawer,
Empty,
Flex,
Row,
Space,
Spin,
Table,
Tag,
Typography,
message,
} from 'antd';
import { CommentOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons';
import { creatorAction, creatorGet, getOne, getList, syncCompetitorAuto } from '@/services/api';
import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
import { usePageActions, usePageInfo } from '@/components/PageActions';
interface CompetitorDetail {
id: string;
platform: string;
platform_account_key: string;
unique_id?: string;
nickname: string;
avatar_url: string;
homepage_url?: string;
tags?: string[];
enabled: boolean;
follower_count?: number | null;
following_count?: number | null;
aweme_count?: number | null;
sync_status?: string;
sync_error?: string;
last_sync_at?: string | null;
next_sync_at?: string | null;
}
const syncStatusMeta: Record<string, { color?: string; label: string }> = {
idle: { color: 'success', label: '监控中' },
running: { color: 'processing', label: '同步中' },
paused: { label: '已暂停' },
failed: { color: 'error', label: '同步失败' },
blocked: { color: 'warning', label: '同步受阻' },
};
function formatCount(value?: number | null): string {
if (value === null || value === undefined) return '—';
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : `${value}`;
}
export default function Page() {
const { id = '' } = useParams<{ id: string }>();
const [competitor, setCompetitor] = useState<CompetitorDetail | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [actionBusy, setActionBusy] = useState(false);
const [works, setWorks] = useState<any[]>([]);
const [workPage, setWorkPage] = useState(1);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
const [worksPending, setWorksPending] = useState(true);
const [worksError, setWorksError] = useState<any>(null);
const [detailWork, setDetailWork] = useState<any>(null);
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
const [metricsPending, setMetricsPending] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const loadCompetitor = useCallback(async () => {
setPending(true);
setError(null);
try {
setCompetitor(await getOne<CompetitorDetail>('creator-competitors', id));
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, [id]);
const loadWorks = useCallback(async () => {
setWorksPending(true);
setWorksError(null);
try {
const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters: { source_id: id } });
setWorks(result.data);
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
} catch (loadError) {
setWorksError(loadError);
} finally {
setWorksPending(false);
}
}, [id, workPage]);
useEffect(() => {
loadCompetitor();
}, [loadCompetitor]);
useEffect(() => {
loadWorks();
}, [loadWorks]);
const syncMeta = syncStatusMeta[competitor?.sync_status ?? ''] || { label: competitor?.sync_status || '—' };
// 页头 content 行:左侧账号标识,右侧操作(返回/同步/暂停恢复),对齐账号详情页形态。
usePageInfo(
competitor ? (
<Flex align="center" gap={8} wrap="wrap">
<Avatar size={28} src={competitor.avatar_url || undefined}>
{(competitor.nickname || competitor.platform_account_key).slice(0, 1).toUpperCase()}
</Avatar>
<Typography.Title level={5} style={{ marginBottom: 0 }} ellipsis>
{competitor.nickname || competitor.platform_account_key}
</Typography.Title>
<Tag color={syncMeta.color}>{syncMeta.label}</Tag>
<Typography.Text type="secondary">
{competitor.unique_id || competitor.platform_account_key} · {platformLabel(competitor.platform)}
</Typography.Text>
</Flex>
) : null,
[competitor?.id, competitor?.nickname, competitor?.sync_status],
);
usePageActions(
competitor ? (
<Space>
<Button onClick={() => history.push('/accounts/monitoring')}>返回监控账号</Button>
<Button
disabled={actionBusy || !competitor.enabled}
onClick={async () => {
setActionBusy(true);
try {
await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await loadCompetitor();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号同步失败'));
} finally {
setActionBusy(false);
}
}}
>
立即同步
</Button>
<Button
type="primary"
disabled={actionBusy}
onClick={async () => {
setActionBusy(true);
try {
await creatorAction(
`/creator/competitors/${encodeURIComponent(competitor.id)}/${competitor.enabled ? 'pause' : 'resume'}`,
);
messageApi.success(competitor.enabled ? '监测账号已暂停。' : '监测账号已恢复。');
await loadCompetitor();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号状态更新失败'));
} finally {
setActionBusy(false);
}
}}
>
{competitor.enabled ? '暂停监控' : '恢复监控'}
</Button>
</Space>
) : null,
[competitor?.id, competitor?.enabled],
);
async function openWorkDetail(work: any) {
setDetailWork(work);
setWorkMetrics([]);
setMetricsPending(true);
try {
setWorkMetrics((await creatorGet(`/creator/works/${encodeURIComponent(work.id)}/metrics`)) || []);
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '作品统计读取失败'));
} finally {
setMetricsPending(false);
}
}
if (pending) return <Card loading />;
if (error || !competitor) return <Alert type="error" showIcon title={error?.message || '监控账号不存在'} />;
return (
<Flex vertical gap={16}>
{contextHolder}
<Card title="账号画像">
<Descriptions
column={{ xs: 1, sm: 2, md: 3 }}
size="small"
items={[
{ key: 'key', label: '平台账号', children: competitor.platform_account_key },
{ key: 'uid', label: '抖音号', children: competitor.unique_id || '—' },
{ key: 'follower', label: '粉丝', children: formatCount(competitor.follower_count) },
{ key: 'following', label: '关注', children: formatCount(competitor.following_count) },
{ key: 'aweme', label: '作品总数', children: formatCount(competitor.aweme_count) },
{ key: 'tags', label: '标签', children: competitor.tags?.length ? competitor.tags.join('、') : '无' },
{ key: 'lastSync', label: '上次同步', children: competitor.last_sync_at ? dateTime(competitor.last_sync_at) : '—' },
{ key: 'nextSync', label: '下次自动同步', children: competitor.next_sync_at ? dateTime(competitor.next_sync_at) : '—' },
{
key: 'syncError',
label: '同步异常',
children: competitor.sync_error ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>{competitor.sync_error}</Typography.Text>
) : (
'—'
),
},
]}
/>
</Card>
<Card
title="作品列表"
extra={
<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。</Typography.Text>
</Flex>
{worksError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
title={worksError.message}
action={<Button size="small" onClick={loadWorks}>重试</Button>}
/>
) : null}
<Spin spinning={worksPending}>
{works.length ? (
<Row gutter={[16, 16]}>
{works.map((work) => (
<Col key={work.id} xs={24} sm={12} lg={8} xl={6}>
<Card
hoverable
style={{ height: '100%' }}
onClick={() => openWorkDetail(work)}
cover={
work.cover_url ? (
<img src={work.cover_url} alt={work.title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />
) : (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>无封面</Typography.Text>
</Flex>
)
}
>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title || '无标题'}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
</Typography.Text>
<Flex gap={16}>
<Typography.Text style={{ fontSize: 12 }}><LikeOutlined /> {work.likes ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><CommentOutlined /> {work.comments_count ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><ShareAltOutlined /> {work.shares ?? '—'}</Typography.Text>
</Flex>
</Flex>
</Card>
</Col>
))}
</Row>
) : (
<Empty description={worksPending ? '加载中…' : '暂无作品;新账号会在下一轮自动采集中拉取作品。'} />
)}
</Spin>
{workPageInfo.total > works.length ? (
<Flex justify="flex-end" style={{ marginTop: 12 }}>
<Space>
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || worksPending}>
上一页
</Button>
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || worksPending}>
下一页
</Button>
</Space>
</Flex>
) : null}
</Card>
<Drawer open={Boolean(detailWork)} onClose={() => setDetailWork(null)} size="large" title="作品统计">
{detailWork ? (
<Flex vertical gap={12}>
{detailWork.cover_url ? (
<img src={detailWork.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} />
) : null}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{detailWork.work_key || detailWork.id}</Typography.Text>
<Typography.Paragraph strong style={{ marginBottom: 0 }}>{detailWork.title || '无标题'}</Typography.Paragraph>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
{detailWork.body || '暂无正文'}
</Typography.Paragraph>
<Space wrap>
<Typography.Text type="secondary">发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}</Typography.Text>
{detailWork.original_url ? (
<Typography.Link href={detailWork.original_url} target="_blank">打开原文</Typography.Link>
) : null}
</Space>
<Table
rowKey="collected_at"
size="small"
loading={metricsPending}
pagination={false}
dataSource={workMetrics}
columns={[
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
]}
locale={{ emptyText: '暂无指标记录。' }}
/>
</Flex>
) : null}
</Drawer>
</Flex>
);
}
+13
View File
@@ -1,6 +1,7 @@
// 领域 API 层:资源路径与语义逐一对照 web.archived/src/shared/api/dataProvider.js。
// 全部经由 requestErrorConfig.request(Basic Auth + 401 全局登出)。
import { AUTH_STORAGE_KEY, RequestError, jsonOptions, request, unauthorized } from '@/requestErrorConfig';
import { isCollectionAccount, platformLabel } from '@/utils/helpers';
export const resourcePaths = {
browsers: '/browsers',
@@ -149,6 +150,18 @@ export function creatorGet(path: string) {
return request(path);
}
// 监控账号同步:自动挑选可用的自有采集账号(登录且授权)作为执行身份。
export async function syncCompetitorAuto(competitor: { id: string; platform: string }): Promise<void> {
const result = await getList({ resource: 'creator-accounts' });
const account = (result.data ?? []).find(
(candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
);
if (!account) {
throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
}
await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
}
export function creatorUpdate(path: string, data: unknown) {
return request(path, jsonOptions('PUT', data));
}
+1
View File
@@ -19,6 +19,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => p === '/accounts/import', title: '账号导入', subtitle: '管理分享链接解析任务,解析成功后自动加入监控账号。' },
{ test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
{ test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },