Files
creator-hub/internal/creator/growth.go
T
rogee 01d1d44a15
douyin-release-gate / verify (push) Failing after 3m23s
feat(creator): 竞品作品指标采集改用匿名浏览器并新增收藏数与爆品增速
- 指标采集不再依赖已登录自有账号:竞品作品到期后经匿名浏览器拉取
  抖音作品详情接口(detail 返回完整点赞/收藏/转发/评论快照)
- 补采收藏数:creator_work 与 creator_work_metric 新增 collect_count
  (迁移 039),works 列表与 detail 解析均映射该字段
- 新增点赞增速:LoadWorksGrowth 按时间窗计算基线增量(full/partial
  覆盖标注),works API 支持 growth_hours/min_likes_growth 筛选排序
- 前端监控详情页:作品卡片展示 24h 点赞增量、支持按增量筛选爆品;
  作品 Drawer 新增增长曲线折线图(@ant-design/charts)与收藏列
2026-09-27 19:44:49 +08:00

163 lines
5.0 KiB
Go

package creator
import (
"context"
"sort"
"time"
)
// WorkGrowth 是作品在观察窗口内的点赞增量,用于爆品筛选。
// Baseline 为窗口起点前最后一个采样点;无基线时以最早采样点近似。
type WorkGrowth struct {
Likes *int64 `json:"likes_growth"`
Coverage string `json:"likes_growth_coverage"` // full=有窗口起点基线;partial=以首点近似
}
// WorkGrowthFilter 增速筛选条件。
type WorkGrowthFilter struct {
Hours int64 // 观察窗口长度,默认 24
MinLikes *int64
}
// normalizeGrowthFilter 校验并补默认值。
func normalizeGrowthFilter(filter WorkGrowthFilter) (WorkGrowthFilter, error) {
if filter.Hours == 0 {
filter.Hours = 24
}
if filter.Hours < 0 || filter.Hours > 24*30 {
return WorkGrowthFilter{}, ErrInvalid
}
if filter.MinLikes != nil && *filter.MinLikes < 0 {
return WorkGrowthFilter{}, ErrInvalid
}
return filter, nil
}
// computeWorkGrowth 从按时间升序的采样点计算观察窗口内的点赞增量。
// points 为空返回零值(无数据);不足两个点或无基线时 coverage=partial。
func computeWorkGrowth(points []MetricPoint, now time.Time, hours int64) WorkGrowth {
// hours 为小时数(接口参数同名同义),窗口起点 = now - hours 小时。
cutoff := now.Add(-time.Duration(hours) * time.Hour)
var baseline, latest *MetricPoint
for index := range points {
point := &points[index]
if point.Likes == nil {
continue
}
if !point.CollectedAt.Before(cutoff) {
// 窗口起点及之后的点属于窗口内;升序遍历取最晚点作为 latest。
latest = point
continue
}
// 窗口起点前最近点作为基线(升序遍历,最后赋值即最近)。
baseline = point
}
if latest == nil {
// 窗口内无采样点:以最后一个窗口外点近似(数据稀疏时的降级)。
for index := range points {
point := &points[index]
if point.Likes == nil {
continue
}
latest = point
}
if latest == nil {
return WorkGrowth{}
}
}
if baseline == nil {
// 无窗口起点前基线:以窗口内最早采样点近似基线(partial)。
for index := range points {
point := &points[index]
if point.Likes == nil {
continue
}
if !point.CollectedAt.Before(cutoff) {
baseline = point
break
}
}
if baseline == nil || baseline == latest {
// 仅有单点:无法计算增量。
return WorkGrowth{Likes: int64Ptr(0), Coverage: "partial"}
}
return WorkGrowth{Likes: int64Ptr(*latest.Likes - *baseline.Likes), Coverage: "partial"}
}
if baseline == latest {
return WorkGrowth{Likes: int64Ptr(0), Coverage: "partial"}
}
return WorkGrowth{Likes: int64Ptr(*latest.Likes - *baseline.Likes), Coverage: "full"}
}
// LoadWorksGrowth 批量加载作品的点赞增量。返回 map[workID]WorkGrowth;无采样点的作品不在 map 中。
func (s *Store) LoadWorksGrowth(ctx context.Context, workIDs []string, filter WorkGrowthFilter) (map[string]WorkGrowth, error) {
filter, err := normalizeGrowthFilter(filter)
if err != nil {
return nil, err
}
if len(workIDs) == 0 {
return map[string]WorkGrowth{}, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT work_id, collected_at, likes FROM creator_work_metric WHERE work_id = ANY($1) AND likes IS NOT NULL ORDER BY work_id, collected_at`, workIDs)
if err != nil {
return nil, databaseError(err)
}
defer rows.Close()
byWork := make(map[string][]MetricPoint)
for rows.Next() {
var workID string
var point MetricPoint
var likes int64
if err := rows.Scan(&workID, &point.CollectedAt, &likes); err != nil {
return nil, databaseError(err)
}
point.CollectedAt = point.CollectedAt.UTC()
value := likes
point.Likes = &value
byWork[workID] = append(byWork[workID], point)
}
if err := rows.Err(); err != nil {
return nil, databaseError(err)
}
now := time.Now().UTC()
result := make(map[string]WorkGrowth, len(byWork))
for workID, points := range byWork {
result[workID] = computeWorkGrowth(points, now, filter.Hours)
}
return result, nil
}
// ApplyWorkGrowthFilter 为作品列表附加增速并按条件过滤、排序(增速降序)。
// 只有设置了 MinLikes 才改变排序;否则保持原顺序仅附加增速。
func ApplyWorkGrowthFilter(works []Work, growth map[string]WorkGrowth, filter WorkGrowthFilter) ([]Work, error) {
filter, err := normalizeGrowthFilter(filter)
if err != nil {
return nil, err
}
result := make([]Work, 0, len(works))
for _, work := range works {
if stat, ok := growth[work.ID]; ok {
work.LikesGrowth = stat.Likes
work.LikesGrowthCoverage = stat.Coverage
}
if filter.MinLikes != nil {
if work.LikesGrowth == nil || *work.LikesGrowth < *filter.MinLikes {
continue
}
}
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 int64Ptr(value int64) *int64 { return &value }