feat: unify owned and competitor work analysis

This commit is contained in:
2026-10-06 14:40:32 +08:00
parent ca3cbb8d37
commit fa7b78797d
15 changed files with 851 additions and 280 deletions
+53 -30
View File
@@ -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
View File
@@ -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 }
+4 -4
View File
@@ -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])
}
}
+4
View File
@@ -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)
}
}
}
+83
View File
@@ -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)
}
}