feat: split account management
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
@@ -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);
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user