fix(creator): 强制同步抢锁失败分类提示——租约被占/竞品停用分别返回可读 409
douyin-release-gate / verify (push) Failing after 4m57s

- ClaimCompetitorSync 失败后经 SyncClaimFailure 复查竞品状态,
  区分 ErrSyncInProgress(该竞品正在同步中)与 ErrSyncDisabled(已停用)
- creatorError 新增两分类映射 409;通用冲突文案保持不变
- 前端竞品强制同步去掉固定'强制同步失败'后缀,直接展示分类提示
- 回归:TestCompetitorSyncClaimFailureClassification(PG 集成)
This commit is contained in:
2026-09-29 15:11:23 +08:00
parent a9ac84f459
commit 998871e3c4
6 changed files with 66 additions and 8 deletions
+6 -1
View File
@@ -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 {
+13
View File
@@ -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
+38
View File
@@ -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())
+7 -5
View File
@@ -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 {
@@ -72,7 +72,7 @@ export default function MonitoringAccountList() {
messageApi.success('强制同步已执行。');
await load();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '强制同步失败'));
messageApi.error(conflictMessage(actionError));
} finally {
setActionBusy('');
}
@@ -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);
}