From 7cb2a0a2b490e8ae61c7ca5d96cc4503a88cb8cc Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 27 Sep 2026 13:22:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(creator):=20=E7=AB=9E=E5=93=81=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20blocked=20=E7=8A=B6=E6=80=81=E4=B9=9F=E6=8E=92?= =?UTF-8?q?=E4=B8=8B=E6=AC=A1=E9=87=8D=E8=AF=95=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E8=8E=B7=E5=8F=96=E6=B0=B8=E4=B9=85=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/controlplane/api/creator.go | 28 +++++++++++++++++-- internal/creator/integration_test.go | 15 ++++++++++ .../{monitoring.tsx => monitoring/index.tsx} | 0 3 files changed, 40 insertions(+), 3 deletions(-) rename web/src/pages/accounts/{monitoring.tsx => monitoring/index.tsx} (100%) diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index bc54bf2..db03a6f 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -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") } } diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go index efbfc02..2ae0764 100644 --- a/internal/creator/integration_test.go +++ b/internal/creator/integration_test.go @@ -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) diff --git a/web/src/pages/accounts/monitoring.tsx b/web/src/pages/accounts/monitoring/index.tsx similarity index 100% rename from web/src/pages/accounts/monitoring.tsx rename to web/src/pages/accounts/monitoring/index.tsx From aeae1049ad51eb5945e6ecebc37c459151412821 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 27 Sep 2026 13:22:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20=E7=9B=91=E6=8E=A7=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AF=A6=E6=83=85=E9=A1=B5=E6=89=BF=E8=BD=BD=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=88=97=E8=A1=A8=EF=BC=8C=E7=82=B9=E5=87=BB=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=BC=B9=E5=87=BA=E7=BB=9F=E8=AE=A1=20Drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounts/MonitoringAccountList.tsx | 20 +- .../pages/accounts/monitoring/$id/index.tsx | 330 ++++++++++++++++++ web/src/services/api.ts | 13 + web/src/utils/metadata.tsx | 1 + 4 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 web/src/pages/accounts/monitoring/$id/index.tsx diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index 8dc4020..429907c 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -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 ( -
+
history.push(`/accounts/monitoring/${row.id}`)} + > @@ -197,7 +193,7 @@ export default function MonitoringAccountList() {
- + e.stopPropagation()}> diff --git a/web/src/pages/accounts/monitoring/$id/index.tsx b/web/src/pages/accounts/monitoring/$id/index.tsx new file mode 100644 index 0000000..b298978 --- /dev/null +++ b/web/src/pages/accounts/monitoring/$id/index.tsx @@ -0,0 +1,330 @@ +// 监控账号详情:形态对齐账号详情页(页头槽 + 卡片流)—— +// 页头 info 为账号标识(头像 + 昵称 + 同步状态 + 平台),actions 为返回/同步/暂停恢复; +// 主体两张 Card:账号画像(Descriptions)与作品列表(Row/Col card 网格,分页)。 +// 点击作品弹出 Drawer 展示统计信息:作品详情 + 指标采集记录表。 +// 作品列表走 GET /creator/works?source_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 = { + 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(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [actionBusy, setActionBusy] = useState(false); + const [works, setWorks] = useState([]); + const [workPage, setWorkPage] = useState(1); + const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false }); + const [worksPending, setWorksPending] = useState(true); + const [worksError, setWorksError] = useState(null); + const [detailWork, setDetailWork] = useState(null); + const [workMetrics, setWorkMetrics] = useState([]); + const [metricsPending, setMetricsPending] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const loadCompetitor = useCallback(async () => { + setPending(true); + setError(null); + try { + setCompetitor(await getOne('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 ? ( + + + {(competitor.nickname || competitor.platform_account_key).slice(0, 1).toUpperCase()} + + + {competitor.nickname || competitor.platform_account_key} + + {syncMeta.label} + + {competitor.unique_id || competitor.platform_account_key} · {platformLabel(competitor.platform)} + + + ) : null, + [competitor?.id, competitor?.nickname, competitor?.sync_status], + ); + + usePageActions( + competitor ? ( + + + + + + ) : 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 ; + if (error || !competitor) return ; + + return ( + + {contextHolder} + + {competitor.sync_error} + ) : ( + '—' + ), + }, + ]} + /> + + } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" /> + } + > + + 共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。 + + {worksError ? ( + 重试} + /> + ) : null} + + {works.length ? ( + + {works.map((work) => ( + + openWorkDetail(work)} + cover={ + work.cover_url ? ( + {work.title + ) : ( + + 无封面 + + ) + } + > + + + {work.title || '无标题'} + + + {work.published_at ? dateTime(work.published_at) : '发布时间待核验'} + + + {work.likes ?? '—'} + {work.comments_count ?? '—'} + {work.shares ?? '—'} + + + + + ))} + + ) : ( + + )} + + {workPageInfo.total > works.length ? ( + + + + + + + ) : null} + + setDetailWork(null)} size="large" title="作品统计"> + {detailWork ? ( + + {detailWork.cover_url ? ( + 作品封面 + ) : null} + ID:{detailWork.work_key || detailWork.id} + {detailWork.title || '无标题'} + + {detailWork.body || '暂无正文'} + + + 发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'} + {detailWork.original_url ? ( + 打开原文 + ) : null} + + dateTime(value) }, + { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' }, + { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' }, + { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' }, + ]} + locale={{ emptyText: '暂无指标记录。' }} + /> + + ) : null} + + + ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index cc4e270..5576b60 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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 { + 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)); } diff --git a/web/src/utils/metadata.tsx b/web/src/utils/metadata.tsx index 653b4ef..8607f8d 100644 --- a/web/src/utils/metadata.tsx +++ b/web/src/utils/metadata.tsx @@ -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: '查看账号状态、登录身份与运行环境绑定。' },