diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 06146d4..d53431f 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -621,6 +621,10 @@ func creatorError(c fiber.Ctx, err error) error { case errors.Is(err, accountdomain.ErrNotFound): // 跨域引用(如账号不存在):account 包哨兵也映射 404,避免 500 掩盖真实语义。 status, message = fiber.StatusNotFound, "resource not found" + case errors.Is(err, creator.ErrSyncInProgress): + status, message = fiber.StatusConflict, creator.ErrSyncInProgress.Error() + case errors.Is(err, creator.ErrSyncDisabled): + status, message = fiber.StatusConflict, creator.ErrSyncDisabled.Error() case errors.Is(err, creator.ErrConflict): status, message = fiber.StatusConflict, creator.ErrConflict.Error() case errors.Is(err, creator.ErrNotFound): @@ -1291,7 +1295,8 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, h return creator.CollectionReport{}, err } if !claimed { - return creator.CollectionReport{}, creator.ErrConflict + // 抢锁失败说明竞品正被其它同步占用或已停用,分类后返回可读的 409 提示。 + return creator.CollectionReport{}, store.SyncClaimFailure(ctx, competitorID) } // blocked(网关不可用等环境暂不可用)也必须排下一次重试:否则 next_sync_at 置空后定时获取对该账号永久失效。 nextRetry := func() *time.Time { diff --git a/internal/creator/content.go b/internal/creator/content.go index 35b48e7..972aaf5 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -282,6 +282,19 @@ func (s *Store) ClaimCompetitorSync(ctx context.Context, id string, force bool, return token, claimed != "", nil } +// SyncClaimFailure 在 ClaimCompetitorSync 抢锁失败后调用:区分租约被占与竞品停用, +// 返回面向用户的分类错误;读不到竞品时退回通用 ErrConflict。 +func (s *Store) SyncClaimFailure(ctx context.Context, id string) error { + competitor, err := s.GetCompetitor(ctx, id) + if err != nil { + return ErrConflict + } + if !competitor.Enabled { + return ErrSyncDisabled + } + return ErrSyncInProgress +} + func (s *Store) ListDueCompetitors(ctx context.Context, now time.Time) ([]Competitor, error) { if now.IsZero() { return nil, ErrInvalid diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go index 557d234..3f03524 100644 --- a/internal/creator/integration_test.go +++ b/internal/creator/integration_test.go @@ -170,6 +170,44 @@ func TestCompetitorShareJobLifecycle(t *testing.T) { } } +// 强制同步抢锁失败的分类:租约被占 → ErrSyncInProgress;竞品停用 → ErrSyncDisabled。 +func TestCompetitorSyncClaimFailureClassification(t *testing.T) { + store, _, ctx := openCreatorIntegrationStore(t) + stamp := fmt.Sprintf("%d", time.Now().UnixNano()) + competitor, err := store.CreateCompetitor(ctx, CompetitorInput{ + Platform: PlatformDouyin, PlatformAccountKey: "sec_uid_claim_" + stamp, UniqueID: "claim_" + stamp, + Nickname: "Claim", HomepageURL: "https://www.douyin.com/user/sec_uid_claim_" + stamp, + }) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + // 首次 force 抢锁成功;租约未释放前第二次 force 抢锁失败 → 正在同步中。 + token, claimed, err := store.ClaimCompetitorSync(ctx, competitor.ID, true, now) + if err != nil || !claimed { + t.Fatalf("first force claim must succeed: err=%v claimed=%v", err, claimed) + } + if _, claimed, err := store.ClaimCompetitorSync(ctx, competitor.ID, true, now.Add(time.Second)); err != nil || claimed { + t.Fatalf("second force claim must fail while lease held: err=%v claimed=%v", err, claimed) + } + if err := store.SyncClaimFailure(ctx, competitor.ID); !errors.Is(err, ErrSyncInProgress) { + t.Fatalf("held lease must classify as sync in progress: err=%v", err) + } + // 释放后停用 → 已停用。 + if err := store.MarkCompetitorSync(ctx, competitor.ID, token, "idle", "", "", nil); err != nil { + t.Fatal(err) + } + if _, err := store.SetCompetitorEnabled(ctx, competitor.ID, false); err != nil { + t.Fatal(err) + } + if _, claimed, err := store.ClaimCompetitorSync(ctx, competitor.ID, true, now.Add(2*time.Second)); err != nil || claimed { + t.Fatalf("disabled competitor must not be claimable: err=%v claimed=%v", err, claimed) + } + if err := store.SyncClaimFailure(ctx, competitor.ID); !errors.Is(err, ErrSyncDisabled) { + t.Fatalf("disabled competitor must classify as disabled: err=%v", err) + } +} + func TestCompetitorUpsertIsIdempotent(t *testing.T) { store, _, ctx := openCreatorIntegrationStore(t) stamp := fmt.Sprintf("%d", time.Now().UnixNano()) diff --git a/internal/creator/logic.go b/internal/creator/logic.go index 6e59b27..12f9bdc 100644 --- a/internal/creator/logic.go +++ b/internal/creator/logic.go @@ -8,11 +8,13 @@ import ( ) var ( - ErrInvalid = errors.New("invalid creator input") - ErrConflict = errors.New("creator resource conflicts with existing state") - ErrNotFound = errors.New("creator resource not found") - ErrUnavailable = errors.New("creator capability is unavailable") - ErrUncertain = errors.New("creator operation result is uncertain") + ErrInvalid = errors.New("invalid creator input") + ErrConflict = errors.New("creator resource conflicts with existing state") + ErrNotFound = errors.New("creator resource not found") + ErrUnavailable = errors.New("creator capability is unavailable") + ErrUncertain = errors.New("creator operation result is uncertain") + ErrSyncInProgress = errors.New("该竞品正在同步中,请稍后再试") + ErrSyncDisabled = errors.New("该竞品已停用,无法同步;启用后再试") ) func ValidatePlatform(platform string) bool { diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index 976f9c5..b01a0c0 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -72,7 +72,7 @@ export default function MonitoringAccountList() { messageApi.success('强制同步已执行。'); await load(); } catch (actionError) { - messageApi.error(conflictMessage(actionError, '强制同步失败')); + messageApi.error(conflictMessage(actionError)); } finally { setActionBusy(''); } diff --git a/web/src/pages/accounts/monitoring/$id/index.tsx b/web/src/pages/accounts/monitoring/$id/index.tsx index b8c3638..d53269e 100644 --- a/web/src/pages/accounts/monitoring/$id/index.tsx +++ b/web/src/pages/accounts/monitoring/$id/index.tsx @@ -81,7 +81,7 @@ export default function Page() { messageApi.success('强制同步已执行。'); await loadCompetitor(); } catch (actionError) { - messageApi.error(conflictMessage(actionError, '强制同步失败')); + messageApi.error(conflictMessage(actionError)); } finally { setActionBusy(false); }