feat: split account management

This commit is contained in:
2026-09-21 16:44:09 +08:00
parent 86a332997e
commit 4f736cba21
25 changed files with 1246 additions and 630 deletions
@@ -135,8 +135,7 @@ func TestCreatorRouteValidationCoverage(t *testing.T) {
{http.MethodPost, "/api/creator/strategies/missing/disable"},
{http.MethodDelete, "/api/creator/strategies/missing"},
{http.MethodPut, "/api/creator/strategies/missing"},
{http.MethodPost, "/api/creator/competitors/preview"},
{http.MethodPost, "/api/creator/competitors"},
{http.MethodPost, "/api/creator/competitor-share-jobs"},
{http.MethodPost, "/api/creator/competitors/missing/pause"},
{http.MethodPost, "/api/creator/competitors/missing/resume"},
{http.MethodPost, "/api/creator/competitors/missing/sync"},
+77 -23
View File
@@ -273,36 +273,45 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
}
return c.JSON(items)
})
app.Post("/api/creator/competitors/preview", func(c fiber.Ctx) error {
var input competitorShareRequest
if err := decodeCreator(c, &input); err != nil {
return creatorError(c, err)
}
preview, err := previewCompetitorShare(c.Context(), store, phaseAStore, hubStore, input.AccountID, input.Platform, input.ShareURL)
app.Get("/api/creator/competitor-share-jobs", func(c fiber.Ctx) error {
items, err := store.ListCompetitorShareJobs(c.Context(), c.Query("platform"), c.Query("status"))
if err != nil {
return creatorError(c, err)
}
return c.JSON(preview)
return c.JSON(items)
})
app.Post("/api/creator/competitors", func(c fiber.Ctx) error {
var request competitorShareRequest
app.Get("/api/creator/competitor-share-jobs/:id", func(c fiber.Ctx) error {
item, err := store.GetCompetitorShareJob(c.Context(), c.Params("id"))
if err != nil {
return creatorError(c, err)
}
return c.JSON(item)
})
app.Post("/api/creator/competitor-share-jobs", func(c fiber.Ctx) error {
var request creator.CompetitorShareJobInput
if err := decodeCreator(c, &request); err != nil {
return creatorError(c, err)
}
preview, err := previewCompetitorShare(c.Context(), store, phaseAStore, hubStore, request.AccountID, request.Platform, request.ShareURL)
platform, err := competitorSharePlatform(request.ShareURL)
if err != nil {
return creatorError(c, err)
}
input := preview.input()
input.Tags = request.Tags
if err := validateXiaohongshuCompetitor(input); err != nil {
return creatorError(c, err)
if request.Platform != "" && request.Platform != platform {
return creatorError(c, creator.ErrInvalid)
}
item, err := store.CreateCompetitor(c.Context(), input)
request.Platform = platform
item, err := store.CreateCompetitorShareJob(c.Context(), request)
if err != nil {
return creatorError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(item)
return c.Status(fiber.StatusAccepted).JSON(item)
})
app.Post("/api/creator/competitor-share-jobs/:id/retry", func(c fiber.Ctx) error {
item, err := store.RetryCompetitorShareJob(c.Context(), c.Params("id"))
if err != nil {
return creatorError(c, err)
}
return c.Status(fiber.StatusAccepted).JSON(item)
})
app.Get("/api/creator/competitors/:id", func(c fiber.Ctx) error {
item, err := store.GetCompetitor(c.Context(), c.Params("id"))
@@ -1524,13 +1533,6 @@ func (browser creatorGatewayBrowser) Media(ctx context.Context, target, destinat
return writeCreatorMedia(destination, data)
}
type competitorShareRequest struct {
AccountID string `json:"account_id"`
Platform string `json:"platform"`
ShareURL string `json:"share_url"`
Tags []string `json:"tags"`
}
type competitorSharePreview struct {
AccountID string `json:"account_id"`
Platform string `json:"platform"`
@@ -1952,6 +1954,49 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
return report, nil
}
func processCompetitorShareJob(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, jobID string) error {
for {
job, leaseToken, claimed, err := store.ClaimCompetitorShareJob(ctx, jobID, time.Now().UTC())
if err != nil {
return err
}
if !claimed {
return nil
}
preview, processErr := previewCompetitorShare(ctx, store, phaseAStore, hubStore, "", job.Platform, job.ShareURL)
competitorID := ""
if processErr == nil {
input := preview.input()
input.Tags = job.Tags
if processErr = validateXiaohongshuCompetitor(input); processErr == nil {
var competitor creator.Competitor
competitor, processErr = store.UpsertCompetitor(ctx, input)
competitorID = competitor.ID
}
}
if processErr == nil {
if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, creator.CompetitorShareJobSucceeded, competitorID, ""); err != nil {
return err
}
logrus.WithFields(logrus.Fields{"job_id": job.ID, "competitor_id": competitorID, "attempts": job.Attempts}).Info("creator competitor share job completed")
return nil
}
status := creator.CompetitorShareJobQueued
if job.Attempts >= creator.MaxCompetitorShareJobAttempts {
status = creator.CompetitorShareJobFailed
}
if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, status, "", processErr.Error()); err != nil {
return errors.Join(processErr, err)
}
logrus.WithFields(logrus.Fields{"job_id": job.ID, "attempts": job.Attempts, "status": status}).WithError(processErr).Warn("creator competitor share job attempt failed")
if status == creator.CompetitorShareJobFailed || ctx.Err() != nil {
return nil
}
}
}
func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store) error {
if store == nil {
return creator.ErrUnavailable
@@ -1961,6 +2006,15 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
return err
}
now := time.Now().UTC()
shareJobs, err := store.ListDueCompetitorShareJobs(ctx, now)
if err != nil {
return err
}
for _, job := range shareJobs {
if err := processCompetitorShareJob(ctx, store, phaseAStore, hubStore, job.ID); err != nil {
logrus.WithError(err).WithField("job_id", job.ID).Warn("creator competitor share job failed")
}
}
competitors, err := store.ListDueCompetitors(ctx, now)
if err != nil {
return err
@@ -52,8 +52,7 @@ func TestCreatorWriteRoutesRejectMalformedInputBeforeStoreAccess(t *testing.T) {
{method: http.MethodPost, path: "/api/creator/relations"},
{method: http.MethodPost, path: "/api/creator/accounts/account-1/strategies"},
{method: http.MethodPut, path: "/api/creator/strategies/strategy-1"},
{method: http.MethodPost, path: "/api/creator/competitors/preview"},
{method: http.MethodPost, path: "/api/creator/competitors"},
{method: http.MethodPost, path: "/api/creator/competitor-share-jobs"},
{method: http.MethodPut, path: "/api/creator/competitors/competitor-1"},
{method: http.MethodPost, path: "/api/creator/competitors/competitor-1/sync"},
{method: http.MethodPost, path: "/api/creator/xiaohongshu/search"},
+233
View File
@@ -0,0 +1,233 @@
package creator
import (
"context"
"database/sql"
"errors"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5/pgtype"
)
const (
CompetitorShareJobQueued = "queued"
CompetitorShareJobProcessing = "processing"
CompetitorShareJobSucceeded = "succeeded"
CompetitorShareJobFailed = "failed"
MaxCompetitorShareJobAttempts = 3
)
func validateCompetitorShareJobInput(input CompetitorShareJobInput) error {
if !ValidatePlatform(input.Platform) || input.ShareURL == "" || utf8.RuneCountInString(input.ShareURL) > 2000 {
return ErrInvalid
}
parsed, err := url.Parse(input.ShareURL)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" {
return ErrInvalid
}
return validateCreatorTags(input.Tags)
}
func normalizeCompetitorShareJobInput(input CompetitorShareJobInput) (CompetitorShareJobInput, error) {
input.Platform = strings.TrimSpace(input.Platform)
input.ShareURL = strings.TrimSpace(input.ShareURL)
if input.Tags == nil {
input.Tags = []string{}
}
if err := validateCompetitorShareJobInput(input); err != nil {
return CompetitorShareJobInput{}, err
}
return input, nil
}
func (s *Store) CreateCompetitorShareJob(ctx context.Context, input CompetitorShareJobInput) (CompetitorShareJob, error) {
input, err := normalizeCompetitorShareJobInput(input)
if err != nil {
return CompetitorShareJob{}, err
}
id := newID("competitor-share-job")
if _, err := s.db.ExecContext(ctx, `
INSERT INTO creator_competitor_share_job (id, platform, share_url, tags)
VALUES ($1, $2, $3, $4)`, id, input.Platform, input.ShareURL, input.Tags); err != nil {
return CompetitorShareJob{}, databaseError(err)
}
return s.GetCompetitorShareJob(ctx, id)
}
func scanCompetitorShareJob(scanner interface{ Scan(...any) error }) (CompetitorShareJob, error) {
var result CompetitorShareJob
var tags pgtype.FlatArray[string]
var competitorID sql.NullString
var leaseUntil, lastAttemptAt, completedAt sql.NullTime
if err := scanner.Scan(&result.ID, &result.Platform, &result.ShareURL, pgtype.NewMap().SQLScanner(&tags),
&result.Status, &result.Attempts, &competitorID, &result.FailureReason, &leaseUntil, &lastAttemptAt,
&completedAt, &result.CreatedAt, &result.UpdatedAt); err != nil {
return CompetitorShareJob{}, err
}
result.Tags = []string(tags)
if competitorID.Valid {
result.CompetitorID = competitorID.String
}
result.LastAttemptAt = nullableTime(lastAttemptAt)
result.CompletedAt = nullableTime(completedAt)
return result, nil
}
const competitorShareJobSelect = `SELECT id, platform, share_url, tags, status, attempts, competitor_id,
failure_reason, lease_until, last_attempt_at, completed_at, created_at, updated_at
FROM creator_competitor_share_job`
func (s *Store) GetCompetitorShareJob(ctx context.Context, id string) (CompetitorShareJob, error) {
result, err := scanCompetitorShareJob(s.db.QueryRowContext(ctx, competitorShareJobSelect+` WHERE id = $1`, id))
return result, rowError(err)
}
func validateCompetitorShareJobStatus(status string) bool {
switch status {
case CompetitorShareJobQueued, CompetitorShareJobProcessing, CompetitorShareJobSucceeded, CompetitorShareJobFailed:
return true
default:
return false
}
}
func (s *Store) ListCompetitorShareJobs(ctx context.Context, platform, status string) ([]CompetitorShareJob, error) {
if platform != "" && !ValidatePlatform(platform) || status != "" && !validateCompetitorShareJobStatus(status) {
return nil, ErrInvalid
}
query := competitorShareJobSelect
conditions := make([]string, 0, 2)
args := make([]any, 0, 2)
if platform != "" {
args = append(args, platform)
conditions = append(conditions, "platform = $"+strconv.Itoa(len(args)))
}
if status != "" {
args = append(args, status)
conditions = append(conditions, "status = $"+strconv.Itoa(len(args)))
}
if len(conditions) > 0 {
query += ` WHERE ` + strings.Join(conditions, ` AND `)
}
query += ` ORDER BY created_at DESC, id`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]CompetitorShareJob, 0)
for rows.Next() {
item, err := scanCompetitorShareJob(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) ListDueCompetitorShareJobs(ctx context.Context, now time.Time) ([]CompetitorShareJob, error) {
if now.IsZero() {
return nil, ErrInvalid
}
rows, err := s.db.QueryContext(ctx, competitorShareJobSelect+` WHERE
(status = 'queued' AND attempts < $1) OR
(status = 'processing' AND lease_until IS NOT NULL AND lease_until <= $2)
ORDER BY created_at, id`, MaxCompetitorShareJobAttempts, now.UTC())
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
result := make([]CompetitorShareJob, 0)
for rows.Next() {
item, err := scanCompetitorShareJob(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) ClaimCompetitorShareJob(ctx context.Context, id string, now time.Time) (CompetitorShareJob, string, bool, error) {
if strings.TrimSpace(id) == "" || now.IsZero() {
return CompetitorShareJob{}, "", false, ErrInvalid
}
token := newID("competitor-share-lease")
claimed, err := scanCompetitorShareJob(s.db.QueryRowContext(ctx, `
UPDATE creator_competitor_share_job
SET status = 'processing',
attempts = CASE WHEN status = 'queued' THEN attempts + 1 ELSE attempts END,
lease_token = $3,
lease_until = $2 + interval '10 minutes',
last_attempt_at = $2,
updated_at = $2
WHERE id = $1 AND (
(status = 'queued' AND attempts < $4) OR
(status = 'processing' AND lease_until IS NOT NULL AND lease_until <= $2)
)
RETURNING id, platform, share_url, tags, status, attempts, competitor_id,
failure_reason, lease_until, last_attempt_at, completed_at, created_at, updated_at`, id, now.UTC(), token, MaxCompetitorShareJobAttempts))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return CompetitorShareJob{}, "", false, nil
}
return CompetitorShareJob{}, "", false, databaseError(err)
}
return claimed, token, true, nil
}
func (s *Store) MarkCompetitorShareJob(ctx context.Context, id, leaseToken, status, competitorID, failureReason string) error {
if strings.TrimSpace(id) == "" || strings.TrimSpace(leaseToken) == "" ||
(status != CompetitorShareJobQueued && status != CompetitorShareJobSucceeded && status != CompetitorShareJobFailed) {
return ErrInvalid
}
if status == CompetitorShareJobSucceeded && strings.TrimSpace(competitorID) == "" || status != CompetitorShareJobSucceeded && competitorID != "" {
return ErrInvalid
}
result, err := s.db.ExecContext(ctx, `
UPDATE creator_competitor_share_job
SET status = $3, competitor_id = $4, failure_reason = $5,
lease_token = '', lease_until = NULL,
completed_at = CASE WHEN $3 = 'queued' THEN NULL ELSE now() END,
updated_at = now()
WHERE id = $1 AND lease_token = $2 AND status = 'processing'`, id, leaseToken, status, nullableString(competitorID), failureReason)
if err != nil {
return databaseError(err)
}
if affected, err := result.RowsAffected(); err != nil {
return databaseError(err)
} else if affected != 1 {
return ErrConflict
}
return nil
}
func (s *Store) RetryCompetitorShareJob(ctx context.Context, id string) (CompetitorShareJob, error) {
id = strings.TrimSpace(id)
if id == "" {
return CompetitorShareJob{}, ErrInvalid
}
result, err := s.db.ExecContext(ctx, `
UPDATE creator_competitor_share_job
SET status = 'queued', attempts = 0, competitor_id = NULL,
lease_token = '', lease_until = NULL, completed_at = NULL, updated_at = now()
WHERE id = $1 AND status = 'failed'`, id)
if err != nil {
return CompetitorShareJob{}, databaseError(err)
}
if affected, err := result.RowsAffected(); err != nil {
return CompetitorShareJob{}, databaseError(err)
} else if affected != 1 {
if _, getErr := s.GetCompetitorShareJob(ctx, id); getErr != nil {
return CompetitorShareJob{}, getErr
}
return CompetitorShareJob{}, ErrConflict
}
return s.GetCompetitorShareJob(ctx, id)
}
@@ -0,0 +1,29 @@
package creator
import (
"errors"
"testing"
)
func TestNormalizeCompetitorShareJobInput(t *testing.T) {
job, err := normalizeCompetitorShareJobInput(CompetitorShareJobInput{
Platform: " douyin ",
ShareURL: " https://v.douyin.com/share-a/ ",
})
if err != nil {
t.Fatal(err)
}
if job.Platform != PlatformDouyin || job.ShareURL != "https://v.douyin.com/share-a/" || job.Tags == nil {
t.Fatalf("normalized job = %+v", job)
}
for _, input := range []CompetitorShareJobInput{
{Platform: PlatformDouyin, ShareURL: "http://v.douyin.com/share-a/"},
{Platform: "unsupported", ShareURL: "https://v.douyin.com/share-a/"},
{Platform: PlatformDouyin, ShareURL: "https://v.douyin.com/share-a/#fragment"},
} {
if _, err := normalizeCompetitorShareJobInput(input); !errors.Is(err, ErrInvalid) {
t.Fatalf("input %+v returned err=%v", input, err)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
package creator
import "context"
func (s *Store) UpsertCompetitor(ctx context.Context, input CompetitorInput) (Competitor, error) {
input, err := normalizeCompetitorInput(input)
if err != nil {
return Competitor{}, err
}
id := newID("competitor")
var returnedID string
if err := s.db.QueryRowContext(ctx, `
INSERT INTO creator_competitor (id, platform, platform_account_key, unique_id, nickname, avatar_url, homepage_url, tags, next_sync_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (platform, platform_account_key) DO UPDATE SET
unique_id = CASE WHEN EXCLUDED.unique_id = '' THEN creator_competitor.unique_id ELSE EXCLUDED.unique_id END,
nickname = CASE WHEN EXCLUDED.nickname = '' THEN creator_competitor.nickname ELSE EXCLUDED.nickname END,
avatar_url = CASE WHEN EXCLUDED.avatar_url = '' THEN creator_competitor.avatar_url ELSE EXCLUDED.avatar_url END,
homepage_url = EXCLUDED.homepage_url,
tags = CASE WHEN cardinality(EXCLUDED.tags) = 0 THEN creator_competitor.tags ELSE EXCLUDED.tags END,
updated_at = now()
RETURNING id`, id, input.Platform, input.PlatformAccountKey, input.UniqueID, input.Nickname,
input.AvatarURL, input.HomepageURL, input.Tags).Scan(&returnedID); err != nil {
return Competitor{}, databaseError(err)
}
return s.GetCompetitor(ctx, returnedID)
}
+10 -2
View File
@@ -36,7 +36,7 @@ func validateCreatorTags(tags []string) error {
return nil
}
func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Competitor, error) {
func normalizeCompetitorInput(input CompetitorInput) (CompetitorInput, error) {
input.Platform = strings.TrimSpace(input.Platform)
input.PlatformAccountKey = strings.TrimSpace(input.PlatformAccountKey)
input.UniqueID = strings.TrimSpace(input.UniqueID)
@@ -50,7 +50,15 @@ func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Co
utf8.RuneCountInString(input.PlatformAccountKey) > 255 || utf8.RuneCountInString(input.UniqueID) > 255 || utf8.RuneCountInString(input.Nickname) > 255 ||
utf8.RuneCountInString(input.AvatarURL) > 1000 || validateHomepage(input.HomepageURL) != nil ||
validateCreatorTags(input.Tags) != nil {
return Competitor{}, ErrInvalid
return CompetitorInput{}, ErrInvalid
}
return input, nil
}
func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Competitor, error) {
input, err := normalizeCompetitorInput(input)
if err != nil {
return Competitor{}, err
}
id := newID("competitor")
if _, err := s.db.ExecContext(ctx, `
+65
View File
@@ -131,6 +131,71 @@ func createIntegrationAccount(t *testing.T, ctx context.Context, phaseAStore *ac
return id
}
func TestCompetitorShareJobLifecycle(t *testing.T) {
store, _, ctx := openCreatorIntegrationStore(t)
stamp := fmt.Sprintf("%d", time.Now().UnixNano())
job, err := store.CreateCompetitorShareJob(ctx, CompetitorShareJobInput{
Platform: PlatformDouyin,
ShareURL: "https://v.douyin.com/share-" + stamp + "/",
Tags: []string{"重点监测"},
})
if err != nil || job.Status != CompetitorShareJobQueued || job.Attempts != 0 {
t.Fatalf("create share job: job=%+v err=%v", job, err)
}
due, err := store.ListDueCompetitorShareJobs(ctx, time.Now().UTC())
if err != nil || len(due) != 1 || due[0].ID != job.ID {
t.Fatalf("list due share jobs: jobs=%+v err=%v", due, err)
}
claimed, leaseToken, ok, err := store.ClaimCompetitorShareJob(ctx, job.ID, time.Now().UTC())
if err != nil || !ok || leaseToken == "" || claimed.Attempts != 1 || claimed.Status != CompetitorShareJobProcessing {
t.Fatalf("claim share job: job=%+v token=%q claimed=%v err=%v", claimed, leaseToken, ok, err)
}
if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, CompetitorShareJobQueued, "", "temporary failure"); err != nil {
t.Fatal(err)
}
claimed, leaseToken, ok, err = store.ClaimCompetitorShareJob(ctx, job.ID, time.Now().UTC())
if err != nil || !ok || leaseToken == "" || claimed.Attempts != 2 {
t.Fatalf("claim retry share job: job=%+v token=%q claimed=%v err=%v", claimed, leaseToken, ok, err)
}
if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, CompetitorShareJobFailed, "", "final failure"); err != nil {
t.Fatal(err)
}
failed, err := store.GetCompetitorShareJob(ctx, job.ID)
if err != nil || failed.Status != CompetitorShareJobFailed || failed.Attempts != 2 || failed.FailureReason != "final failure" {
t.Fatalf("failed share job: job=%+v err=%v", failed, err)
}
requeued, err := store.RetryCompetitorShareJob(ctx, job.ID)
if err != nil || requeued.Status != CompetitorShareJobQueued || requeued.Attempts != 0 || requeued.FailureReason != "final failure" {
t.Fatalf("requeue share job: job=%+v err=%v", requeued, err)
}
}
func TestCompetitorUpsertIsIdempotent(t *testing.T) {
store, _, ctx := openCreatorIntegrationStore(t)
stamp := fmt.Sprintf("%d", time.Now().UnixNano())
input := CompetitorInput{
Platform: PlatformDouyin,
PlatformAccountKey: "sec_uid_upsert_" + stamp,
UniqueID: "upsert_" + stamp,
Nickname: "Competitor",
HomepageURL: "https://www.douyin.com/user/sec_uid_upsert_" + stamp,
Tags: []string{"重点监测"},
}
first, err := store.UpsertCompetitor(ctx, input)
if err != nil {
t.Fatal(err)
}
second, err := store.UpsertCompetitor(ctx, CompetitorInput{
Platform: input.Platform,
PlatformAccountKey: input.PlatformAccountKey,
Nickname: "Updated Competitor",
HomepageURL: input.HomepageURL,
})
if err != nil || second.ID != first.ID || second.Nickname != "Updated Competitor" || len(second.Tags) != 1 {
t.Fatalf("upsert competitor: first=%+v second=%+v err=%v", first, second, err)
}
}
func TestCreatorPostgresContentAndWorkflow(t *testing.T) {
store, phaseAStore, ctx := openCreatorIntegrationStore(t)
stamp := fmt.Sprintf("%d", time.Now().UnixNano())
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS creator_competitor_share_job (
id text PRIMARY KEY,
platform text NOT NULL CHECK (platform IN ('douyin', 'xiaohongshu')),
share_url text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0 AND attempts <= 3),
competitor_id text REFERENCES creator_competitor(id) ON DELETE SET NULL,
failure_reason text NOT NULL DEFAULT '',
lease_token text NOT NULL DEFAULT '',
lease_until timestamptz,
last_attempt_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS creator_competitor_share_job_due_idx
ON creator_competitor_share_job (status, lease_until, created_at);
+21
View File
@@ -133,6 +133,27 @@ type CompetitorInput struct {
Tags []string `json:"tags"`
}
type CompetitorShareJob struct {
ID string `json:"id"`
Platform string `json:"platform"`
ShareURL string `json:"share_url"`
Tags []string `json:"tags"`
Status string `json:"status"`
Attempts int `json:"attempts"`
CompetitorID string `json:"competitor_id,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CompetitorShareJobInput struct {
Platform string `json:"platform"`
ShareURL string `json:"share_url"`
Tags []string `json:"tags"`
}
type WorkSource struct {
Platform string `json:"platform"`
SourceType string `json:"source_type"`
+4
View File
@@ -80,6 +80,9 @@ var migration035 string
//go:embed migrations/036_competitor_unique_id.sql
var migration036 string
//go:embed migrations/037_competitor_share_jobs.sql
var migration037 string
type SecretReference struct {
ID string
Provider string
@@ -177,6 +180,7 @@ func (s *Store) migrate(ctx context.Context) error {
{version: 34, sql: migration034},
{version: 35, sql: migration035},
{version: 36, sql: migration036},
{version: 37, sql: migration037},
}
for _, migration := range migrations {
var applied bool