feat: unify owned and competitor work analysis
This commit is contained in:
+53
-30
@@ -510,35 +510,56 @@ func (s *Store) GetWorkByKey(ctx context.Context, platform, workKey string) (Wor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) {
|
||||
query, args := workSelect, make([]any, 0, 8)
|
||||
where := make([]string, 0, 7)
|
||||
func buildWorkQuery(filter WorkFilter) (string, []any, error) {
|
||||
column := filter.SortBy
|
||||
if column == "" {
|
||||
column = "published_at"
|
||||
}
|
||||
switch column {
|
||||
case "published_at", "likes", "comments_count", "shares", "collect_count", "play_count", "likes_growth":
|
||||
default:
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
order := filter.SortOrder
|
||||
if order == "" {
|
||||
order = "desc"
|
||||
}
|
||||
if order != "asc" && order != "desc" {
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
if column == "likes_growth" {
|
||||
// 增量在读取采样后计算;先保持发布时间与 ID 的稳定顺序。
|
||||
column, order = "published_at", "desc"
|
||||
}
|
||||
if filter.PublishedAfter != nil && filter.PublishedBefore != nil && filter.PublishedAfter.After(*filter.PublishedBefore) {
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
query, args := workSelect, make([]any, 0, 11)
|
||||
where := make([]string, 0, 11)
|
||||
add := func(clause string, value any) {
|
||||
args = append(args, value)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if filter.Platform != "" {
|
||||
if !ValidatePlatform(filter.Platform) {
|
||||
return nil, ErrInvalid
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
add("platform = $%d", filter.Platform)
|
||||
}
|
||||
if filter.PublishedAtStatus != "" {
|
||||
if filter.PublishedAtStatus != "verified" && filter.PublishedAtStatus != "future" && filter.PublishedAtStatus != "pending_verification" && filter.PublishedAtStatus != "invalid" {
|
||||
return nil, ErrInvalid
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
add("published_at_status = $%d", filter.PublishedAtStatus)
|
||||
}
|
||||
if filter.SourceType != "" {
|
||||
if filter.SourceType != SourceOwned && filter.SourceType != SourceCompetitor {
|
||||
return nil, ErrInvalid
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
args = append(args, filter.SourceType)
|
||||
where = append(where, fmt.Sprintf(`creator_work.source_type = $%d`, len(args)))
|
||||
add("creator_work.source_type = $%d", filter.SourceType)
|
||||
}
|
||||
if filter.SourceID != "" {
|
||||
args = append(args, filter.SourceID)
|
||||
where = append(where, fmt.Sprintf(`creator_work.source_id = $%d`, len(args)))
|
||||
add("creator_work.source_id = $%d", filter.SourceID)
|
||||
}
|
||||
if filter.PublishedAfter != nil {
|
||||
add("published_at >= $%d", filter.PublishedAfter.UTC())
|
||||
@@ -546,31 +567,30 @@ func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error
|
||||
if filter.PublishedBefore != nil {
|
||||
add("published_at <= $%d", filter.PublishedBefore.UTC())
|
||||
}
|
||||
if filter.MinLikes != nil {
|
||||
if *filter.MinLikes < 0 {
|
||||
return nil, ErrInvalid
|
||||
for _, metric := range []struct {
|
||||
column string
|
||||
minimum *int64
|
||||
}{{"likes", filter.MinLikes}, {"comments_count", filter.MinComments}, {"shares", filter.MinShares}, {"collect_count", filter.MinCollectCount}, {"play_count", filter.MinPlayCount}} {
|
||||
if metric.minimum == nil {
|
||||
continue
|
||||
}
|
||||
where = append(where, fmt.Sprintf("likes IS NOT NULL AND likes >= $%d", len(args)+1))
|
||||
args = append(args, *filter.MinLikes)
|
||||
}
|
||||
if filter.MinComments != nil {
|
||||
if *filter.MinComments < 0 {
|
||||
return nil, ErrInvalid
|
||||
if *metric.minimum < 0 {
|
||||
return "", nil, ErrInvalid
|
||||
}
|
||||
where = append(where, fmt.Sprintf("comments_count IS NOT NULL AND comments_count >= $%d", len(args)+1))
|
||||
args = append(args, *filter.MinComments)
|
||||
}
|
||||
if filter.MinShares != nil {
|
||||
if *filter.MinShares < 0 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
where = append(where, fmt.Sprintf("shares IS NOT NULL AND shares >= $%d", len(args)+1))
|
||||
args = append(args, *filter.MinShares)
|
||||
add(metric.column+" IS NOT NULL AND "+metric.column+" >= $%d", *metric.minimum)
|
||||
}
|
||||
if len(where) > 0 {
|
||||
query += ` WHERE ` + strings.Join(where, ` AND `)
|
||||
}
|
||||
query += ` ORDER BY published_at DESC NULLS LAST, id`
|
||||
query += " ORDER BY " + column + " " + strings.ToUpper(order) + " NULLS LAST, id"
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) {
|
||||
query, args, err := buildWorkQuery(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, databaseError(err)
|
||||
@@ -780,7 +800,7 @@ func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, page
|
||||
if err != nil {
|
||||
return Page[Work]{}, err
|
||||
}
|
||||
if filter.Growth.Hours != 0 || filter.Growth.MinLikes != nil {
|
||||
if filter.Growth.Hours != 0 || filter.Growth.MinLikes != nil || filter.SortBy == "likes_growth" {
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.ID)
|
||||
@@ -794,6 +814,9 @@ func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, page
|
||||
return Page[Work]{}, err
|
||||
}
|
||||
}
|
||||
if filter.SortBy == "likes_growth" {
|
||||
sortWorksGrowth(items, filter.SortOrder)
|
||||
}
|
||||
return slicePage(items, page, pageSize)
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -127,8 +127,7 @@ func (s *Store) LoadWorksGrowth(ctx context.Context, workIDs []string, filter Wo
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ApplyWorkGrowthFilter 为作品列表附加增速并按条件过滤、排序(增速降序)。
|
||||
// 只有设置了 MinLikes 才改变排序;否则保持原顺序仅附加增速。
|
||||
// ApplyWorkGrowthFilter 为作品列表附加增量并按条件过滤,不改变显式选择的顺序。
|
||||
func ApplyWorkGrowthFilter(works []Work, growth map[string]WorkGrowth, filter WorkGrowthFilter) ([]Work, error) {
|
||||
filter, err := normalizeGrowthFilter(filter)
|
||||
if err != nil {
|
||||
@@ -147,16 +146,20 @@ func ApplyWorkGrowthFilter(works []Work, growth map[string]WorkGrowth, filter Wo
|
||||
}
|
||||
result = append(result, work)
|
||||
}
|
||||
if filter.MinLikes != nil {
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
gi, gj := result[i].LikesGrowth, result[j].LikesGrowth
|
||||
if gi == nil || gj == nil {
|
||||
return gj == nil && gi != nil
|
||||
}
|
||||
return *gi > *gj
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sortWorksGrowth(works []Work, order string) {
|
||||
sort.SliceStable(works, func(i, j int) bool {
|
||||
left, right := works[i].LikesGrowth, works[j].LikesGrowth
|
||||
if left == nil || right == nil {
|
||||
return left != nil && right == nil
|
||||
}
|
||||
if order == "asc" {
|
||||
return *left < *right
|
||||
}
|
||||
return *left > *right
|
||||
})
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 { return &value }
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestComputeWorkGrowth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyWorkGrowthFilterSortsAndFilters(t *testing.T) {
|
||||
func TestApplyWorkGrowthFilterPreservesOrderAndFilters(t *testing.T) {
|
||||
mk := func(id string, growth int64) (Work, WorkGrowth) {
|
||||
value := growth
|
||||
return Work{ID: id}, WorkGrowth{Likes: &value, Coverage: "full"}
|
||||
@@ -73,10 +73,10 @@ func TestApplyWorkGrowthFilterSortsAndFilters(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(filtered) != 2 || filtered[0].ID != "b" || filtered[1].ID != "a" {
|
||||
t.Fatalf("unexpected filter/sort result: %+v", filtered)
|
||||
if len(filtered) != 2 || filtered[0].ID != "a" || filtered[1].ID != "b" {
|
||||
t.Fatalf("unexpected filter/order result: %+v", filtered)
|
||||
}
|
||||
if filtered[0].LikesGrowth == nil || *filtered[0].LikesGrowth != 900 {
|
||||
if filtered[0].LikesGrowth == nil || *filtered[0].LikesGrowth != 100 {
|
||||
t.Fatalf("growth not attached: %+v", filtered[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,10 @@ type WorkFilter struct {
|
||||
MinLikes *int64
|
||||
MinComments *int64
|
||||
MinShares *int64
|
||||
MinCollectCount *int64
|
||||
MinPlayCount *int64
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Growth WorkGrowthFilter // 点赞增量筛选(爆品),空窗口不启用
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreatorPostgresWorkAnalysisSourcesFiltersAndOrdering(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
ownedID := createIntegrationAccount(t, ctx, accounts, "analysis-owned")
|
||||
competitor, err := store.CreateCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "analysis-competitor", Nickname: "分析竞品", HomepageURL: "https://www.douyin.com/user/analysis-competitor"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
older := now.Add(-72 * time.Hour)
|
||||
newer := now.Add(-48 * time.Hour)
|
||||
inputs := []WorkInput{
|
||||
{Platform: PlatformDouyin, WorkKey: "zero", SourceType: SourceOwned, SourceID: ownedID, PublishedAt: &older, Likes: int64Ptr(0), CommentsCount: int64Ptr(0), Shares: int64Ptr(0), CollectCount: int64Ptr(0), PlayCount: int64Ptr(0)},
|
||||
{Platform: PlatformDouyin, WorkKey: "positive", SourceType: SourceOwned, SourceID: ownedID, PublishedAt: &newer, Likes: int64Ptr(10), CommentsCount: int64Ptr(10), Shares: int64Ptr(10), CollectCount: int64Ptr(10), PlayCount: int64Ptr(10)},
|
||||
{Platform: PlatformDouyin, WorkKey: "unknown", SourceType: SourceOwned, SourceID: ownedID},
|
||||
{Platform: PlatformDouyin, WorkKey: "competitor", SourceType: SourceCompetitor, SourceID: competitor.ID, PublishedAt: &newer, Likes: int64Ptr(999)},
|
||||
}
|
||||
works := make([]Work, len(inputs))
|
||||
for i, input := range inputs {
|
||||
work, _, err := store.UpsertWork(ctx, input, now.Add(-26*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
works[i] = work
|
||||
}
|
||||
for _, field := range []string{"published_at", "likes", "comments_count", "shares", "collect_count", "play_count"} {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
t.Run(fmt.Sprintf("%s_%s", field, order), func(t *testing.T) {
|
||||
filter := WorkFilter{SourceType: SourceOwned, SortBy: field, SortOrder: order}
|
||||
first := works[0].ID
|
||||
second := works[1].ID
|
||||
if order == "desc" {
|
||||
first, second = second, first
|
||||
}
|
||||
for pageNumber, id := range []string{first, second, works[2].ID} {
|
||||
page, err := store.ListWorksPage(ctx, filter, pageNumber+1, 1)
|
||||
if err != nil || page.Total != 3 || len(page.Data) != 1 || page.Data[0].ID != id {
|
||||
t.Fatalf("page=%+v expected=%s err=%v", page, id, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, filter := range []WorkFilter{
|
||||
{SourceType: SourceOwned, SourceID: ownedID, MinCollectCount: int64Ptr(0), MinPlayCount: int64Ptr(0)},
|
||||
{SourceType: SourceOwned, MinLikes: int64Ptr(0), MinComments: int64Ptr(0), MinShares: int64Ptr(0)},
|
||||
{SourceType: SourceOwned, PublishedAtStatus: "verified"},
|
||||
} {
|
||||
page, err := store.ListWorksPage(ctx, filter, 1, 10)
|
||||
if err != nil || page.Total != 2 {
|
||||
t.Fatalf("filter=%+v page=%+v err=%v", filter, page, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceCompetitor, SourceID: competitor.ID}, 1, 10)
|
||||
if err != nil || page.Total != 1 || page.Data[0].ID != works[3].ID {
|
||||
t.Fatalf("competitor leaked owned works: %+v %v", page, err)
|
||||
}
|
||||
page, err = store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: "missing"}, 1, 10)
|
||||
if err != nil || page.Total != 0 || page.Data == nil {
|
||||
t.Fatalf("empty account: %+v %v", page, err)
|
||||
}
|
||||
settings, err := store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, growth := range []int64{50, 5} {
|
||||
for _, point := range []struct {
|
||||
at time.Time
|
||||
likes int64
|
||||
}{{now.Add(-25 * time.Hour), 100}, {now.Add(-time.Hour), 100 + growth}} {
|
||||
if _, err := store.RecordMetric(ctx, MetricInput{WorkID: works[i].ID, CollectedAt: point.at, Likes: int64Ptr(point.likes)}, settings, point.at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
filter := WorkFilter{SourceType: SourceOwned, SortBy: "likes_growth", SortOrder: order, Growth: WorkGrowthFilter{Hours: 24}}
|
||||
expected := works[0].ID
|
||||
if order == "asc" {
|
||||
expected = works[1].ID
|
||||
}
|
||||
page, err = store.ListWorksPage(ctx, filter, 1, 1)
|
||||
if err != nil || page.Total != 3 || page.Data[0].ID != expected {
|
||||
t.Fatalf("growth ordering: %+v err=%v", page, err)
|
||||
}
|
||||
filter.Growth.MinLikes = int64Ptr(10)
|
||||
page, err = store.ListWorksPage(ctx, filter, 1, 1)
|
||||
if err != nil || page.Total != 1 || page.Data[0].ID != works[0].ID {
|
||||
t.Fatalf("growth threshold before page: %+v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildWorkQueryAnalysis(t *testing.T) {
|
||||
for _, field := range []string{"published_at", "likes", "comments_count", "shares", "collect_count", "play_count", "likes_growth"} {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
query, _, err := buildWorkQuery(WorkFilter{SortBy: field, SortOrder: order})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", field, order, err)
|
||||
}
|
||||
expected := field + " " + strings.ToUpper(order) + " NULLS LAST"
|
||||
if field == "likes_growth" {
|
||||
expected = "published_at DESC NULLS LAST"
|
||||
}
|
||||
if !strings.Contains(query, expected) || !strings.HasSuffix(query, ", id") {
|
||||
t.Fatalf("unexpected query: %s", query)
|
||||
}
|
||||
}
|
||||
}
|
||||
after := time.Now().Add(-time.Hour)
|
||||
before := time.Now()
|
||||
query, args, err := buildWorkQuery(WorkFilter{Platform: "douyin", SourceType: SourceOwned, SourceID: "owned-1", PublishedAtStatus: "verified", PublishedAfter: &after, PublishedBefore: &before, MinLikes: int64Ptr(0), MinComments: int64Ptr(1), MinShares: int64Ptr(2), MinCollectCount: int64Ptr(3), MinPlayCount: int64Ptr(4)})
|
||||
if err != nil || len(args) != 11 {
|
||||
t.Fatalf("query=%s args=%v err=%v", query, args, err)
|
||||
}
|
||||
for _, column := range []string{"likes", "comments_count", "shares", "collect_count", "play_count"} {
|
||||
if !strings.Contains(query, column+" IS NOT NULL AND "+column+" >=") {
|
||||
t.Fatalf("nil threshold must be excluded: %s", query)
|
||||
}
|
||||
}
|
||||
query, _, err = buildWorkQuery(WorkFilter{})
|
||||
if err != nil || !strings.Contains(query, "ORDER BY published_at DESC NULLS LAST, id") {
|
||||
t.Fatalf("default: %s %v", query, err)
|
||||
}
|
||||
for _, status := range []string{"verified", "future", "pending_verification", "invalid"} {
|
||||
if _, _, err := buildWorkQuery(WorkFilter{PublishedAtStatus: status, SourceType: SourceCompetitor}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, filter := range []WorkFilter{
|
||||
{SortBy: "likes; DROP TABLE creator_work"}, {SortOrder: "invalid"}, {Platform: "invalid"}, {SourceType: "invalid"}, {PublishedAtStatus: "unknown"},
|
||||
{PublishedAfter: &before, PublishedBefore: &after},
|
||||
{MinLikes: int64Ptr(-1)}, {MinComments: int64Ptr(-1)}, {MinShares: int64Ptr(-1)}, {MinCollectCount: int64Ptr(-1)}, {MinPlayCount: int64Ptr(-1)},
|
||||
} {
|
||||
if _, _, err := buildWorkQuery(filter); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid filter accepted: %+v, %v", filter, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalysisGrowthSortBeforePagination(t *testing.T) {
|
||||
works := []Work{{ID: "unknown"}, {ID: "low", LikesGrowth: int64Ptr(1)}, {ID: "high", LikesGrowth: int64Ptr(9)}, {ID: "equal", LikesGrowth: int64Ptr(9)}}
|
||||
for _, tc := range []struct {
|
||||
order string
|
||||
ids []string
|
||||
}{
|
||||
{"desc", []string{"high", "equal", "low", "unknown"}},
|
||||
{"asc", []string{"low", "high", "equal", "unknown"}},
|
||||
} {
|
||||
items := append([]Work(nil), works...)
|
||||
sortWorksGrowth(items, tc.order)
|
||||
for i, id := range tc.ids {
|
||||
if items[i].ID != id {
|
||||
t.Fatalf("%s: %+v", tc.order, items)
|
||||
}
|
||||
}
|
||||
page, err := slicePage(items, 2, 2)
|
||||
if err != nil || page.Total != 4 || page.Data[0].ID != tc.ids[2] {
|
||||
t.Fatalf("page=%+v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
sortWorksGrowth([]Work{{ID: "a"}, {ID: "b"}}, "desc")
|
||||
filtered, err := ApplyWorkGrowthFilter(works, nil, WorkGrowthFilter{MinLikes: int64Ptr(0)})
|
||||
if err != nil || len(filtered) != 3 || filtered[0].ID != "low" {
|
||||
t.Fatalf("filter must preserve explicit sorting: %+v %v", filtered, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user