diff --git a/internal/controlplane/api/comment_aggregation.go b/internal/controlplane/api/comment_aggregation.go new file mode 100644 index 0000000..1ed327f --- /dev/null +++ b/internal/controlplane/api/comment_aggregation.go @@ -0,0 +1,40 @@ +package api + +import ( + "strconv" + "strings" + "time" + + "git.ipao.vip/rogee/creator-hub/internal/creator" + "github.com/gofiber/fiber/v3" +) + +func creatorCommentAggregationFilter(c fiber.Ctx) (creator.CommentAggregationFilter, error) { + hours, err := strconv.Atoi(c.Query("hours", "24")) + if err != nil { + return creator.CommentAggregationFilter{}, creator.ErrInvalid + } + filter := creator.CommentAggregationFilter{SourceType: strings.TrimSpace(c.Query("source_type")), SourceID: strings.TrimSpace(c.Query("source_id")), Hours: hours} + return filter, filter.Validate() +} + +func registerCommentAggregation(app *fiber.App, store *creator.Store) { + app.Get("/api/creator/comments/aggregation", func(c fiber.Ctx) error { + filter, err := creatorCommentAggregationFilter(c) + if err != nil { + return creatorError(c, err) + } + page, pageSize, paged, err := creatorPageQuery(c) + if err != nil { + return creatorError(c, err) + } + if !paged { + page, pageSize = 1, 25 + } + result, err := store.ListCommentAggregationPage(c.Context(), filter, page, pageSize, time.Now().UTC()) + if err != nil { + return creatorError(c, err) + } + return c.JSON(result) + }) +} diff --git a/internal/controlplane/api/comment_aggregation_test.go b/internal/controlplane/api/comment_aggregation_test.go new file mode 100644 index 0000000..8effa54 --- /dev/null +++ b/internal/controlplane/api/comment_aggregation_test.go @@ -0,0 +1,112 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "git.ipao.vip/rogee/creator-hub/internal/creator" + "github.com/gofiber/fiber/v3" +) + +func TestCreatorCommentAggregationRoutes(t *testing.T) { + app := fiber.New() + registerCreatorWithServices(app, nil, nil, nil, nil) + for _, query := range []string{"", "source_type=other", "source_type=owned&hours=bad", "source_type=owned&hours=2", "source_type=owned&page=bad", "source_type=owned&page=0", "source_type=owned&page_size=101"} { + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?"+query, nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("query=%s status=%d", query, response.StatusCode) + } + } +} + +func TestCreatorCommentAggregationRouteIntegration(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL") + } + store, _, ctx := openCreatorIntegrationStoreForAPITest(t, databaseURL) + now := time.Now().UTC() + competitor, err := store.UpsertCompetitor(ctx, creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "aggregation-rival", Nickname: "竞品账号", HomepageURL: "https://www.douyin.com/user/aggregation-rival"}) + if err != nil { + t.Fatal(err) + } + work, _, err := store.UpsertWork(ctx, creator.WorkInput{Platform: creator.PlatformDouyin, WorkKey: "aggregation-work", SourceType: creator.SourceCompetitor, SourceID: competitor.ID, Title: "作品"}, now) + if err != nil { + t.Fatal(err) + } + comment, _, err := store.SaveComment(ctx, creator.CommentInput{Platform: creator.PlatformDouyin, WorkID: work.ID, CommentKey: "aggregation-comment", Content: "评论", CommentType: "top_level", PublishedAt: &now}) + if err != nil { + t.Fatal(err) + } + app := fiber.New() + registerCreatorWithServices(app, store, nil, nil, nil) + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?source_type=competitor&source_id="+competitor.ID, nil)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status=%d", response.StatusCode) + } + var page creator.Page[creator.AggregatedComment] + if err := json.NewDecoder(response.Body).Decode(&page); err != nil { + t.Fatal(err) + } + if page.Page != 1 || page.PageSize != 25 || page.Total != 1 || len(page.Data) != 1 || page.Data[0].ID != comment.ID || page.Data[0].SourceName != "竞品账号" { + t.Fatalf("page=%+v", page) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + response, err = app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?source_type=competitor", nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusInternalServerError { + t.Fatalf("database failure status=%d", response.StatusCode) + } +} + +func TestCreatorCommentAggregationFilter(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + filter, err := creatorCommentAggregationFilter(c) + if err != nil { + return creatorError(c, err) + } + return c.JSON(filter) + }) + for _, query := range []string{"source_type=owned", "source_type=competitor&source_id=account-1&hours=6", "source_type=owned&hours=168"} { + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/?"+query, nil)) + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("query=%s response=%v err=%v", query, response, err) + } + var filter creator.CommentAggregationFilter + if err := json.NewDecoder(response.Body).Decode(&filter); err != nil { + t.Fatal(err) + } + response.Body.Close() + if query == "source_type=owned" && filter.Hours != 24 { + t.Fatalf("default=%+v", filter) + } + if query == "source_type=competitor&source_id=account-1&hours=6" && (filter.SourceType != creator.SourceCompetitor || filter.SourceID != "account-1" || filter.Hours != 6) { + t.Fatalf("filter=%+v", filter) + } + } + for _, query := range []string{"", "source_type=other", "source_type=owned&hours=bad", "source_type=owned&hours=0", "source_type=owned&hours=-1", "source_type=owned&hours=2", "source_type=owned&hours=169"} { + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/?"+query, nil)) + if err != nil || response.StatusCode != http.StatusBadRequest { + t.Fatalf("query=%s response=%v err=%v", query, response, err) + } + response.Body.Close() + } +} diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 91ce3e0..4d7ce33 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -440,6 +440,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto c.Set(fiber.HeaderCacheControl, "private, max-age=86400") return c.SendFile(path) }) + registerCommentAggregation(app, store) app.Get("/api/creator/comments", func(c fiber.Ctx) error { page, pageSize, paged, err := creatorPageQuery(c) if err != nil { diff --git a/internal/creator/comment_aggregation.go b/internal/creator/comment_aggregation.go new file mode 100644 index 0000000..0e6b8da --- /dev/null +++ b/internal/creator/comment_aggregation.go @@ -0,0 +1,102 @@ +package creator + +import ( + "context" + "fmt" + "time" +) + +// CommentAggregationFilter uses comment publication time, not collection time. +type CommentAggregationFilter struct { + SourceType string + SourceID string + Hours int +} + +func (filter CommentAggregationFilter) Validate() error { + if filter.SourceType != SourceOwned && filter.SourceType != SourceCompetitor { + return ErrInvalid + } + switch filter.Hours { + case 1, 6, 12, 24, 72, 120, 168: + return nil + default: + return ErrInvalid + } +} + +// AggregatedComment adds the owning account and work to an already collected comment. +type AggregatedComment struct { + ID string `json:"id"` + AuthorUID string `json:"author_uid"` + AuthorName string `json:"author_name"` + Content string `json:"content"` + PublishedAt time.Time `json:"published_at"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` + SourceAccountKey string `json:"source_account_key"` + WorkID string `json:"work_id"` + WorkKey string `json:"work_key"` + WorkTitle string `json:"work_title"` + WorkURL string `json:"work_url"` +} + +func buildCommentAggregationQuery(filter CommentAggregationFilter, now time.Time) (string, []any, error) { + if err := filter.Validate(); err != nil { + return "", nil, err + } + query := ` FROM creator_comment c + JOIN creator_work w ON w.id = c.work_id + LEFT JOIN social_account a ON w.source_type = 'owned' AND a.account_id = w.source_id + LEFT JOIN creator_competitor rival ON w.source_type = 'competitor' AND rival.competitor_id = w.source_id + WHERE w.source_type = $1 AND c.published_at >= $2 AND c.published_at <= $3` + args := []any{filter.SourceType, now.UTC().Add(-time.Duration(filter.Hours) * time.Hour), now.UTC()} + if filter.SourceID != "" { + args = append(args, filter.SourceID) + query += " AND w.source_id = $4" + } + return query, args, nil +} + +func (s *Store) ListCommentAggregationPage(ctx context.Context, filter CommentAggregationFilter, page, pageSize int, now time.Time) (Page[AggregatedComment], error) { + offset, limit, err := pageBounds(page, pageSize) + if err != nil { + return Page[AggregatedComment]{}, err + } + from, args, err := buildCommentAggregationQuery(filter, now) + if err != nil { + return Page[AggregatedComment]{}, err + } + result := Page[AggregatedComment]{Data: []AggregatedComment{}, Page: page, PageSize: pageSize} + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*)"+from, args...).Scan(&result.Total); err != nil { + return Page[AggregatedComment]{}, fmt.Errorf("count comment aggregation source=%s account=%s hours=%d: %w", filter.SourceType, filter.SourceID, filter.Hours, err) + } + query := `SELECT c.comment_id, c.author_uid, c.author_name, c.content, c.published_at, + w.source_type, w.source_id, + CASE WHEN w.source_type = 'owned' THEN COALESCE(a.name, '') ELSE COALESCE(rival.nickname, '') END, + CASE WHEN w.source_type = 'owned' THEN COALESCE(a.platform_account_key, '') ELSE COALESCE(rival.platform_account_key, '') END, + w.work_id, w.work_key, w.title, w.original_url` + from + query += fmt.Sprintf(" ORDER BY c.published_at DESC, c.id DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return Page[AggregatedComment]{}, fmt.Errorf("list comment aggregation source=%s account=%s hours=%d page=%d: %w", filter.SourceType, filter.SourceID, filter.Hours, page, err) + } + defer rows.Close() + for rows.Next() { + var item AggregatedComment + if err := rows.Scan(&item.ID, &item.AuthorUID, &item.AuthorName, &item.Content, &item.PublishedAt, + &item.SourceType, &item.SourceID, &item.SourceName, &item.SourceAccountKey, + &item.WorkID, &item.WorkKey, &item.WorkTitle, &item.WorkURL); err != nil { + return Page[AggregatedComment]{}, fmt.Errorf("scan comment aggregation: %w", err) + } + item.PublishedAt = item.PublishedAt.UTC() + result.Data = append(result.Data, item) + } + if err := rows.Err(); err != nil { + return Page[AggregatedComment]{}, fmt.Errorf("read comment aggregation: %w", err) + } + result.HasNext = offset+len(result.Data) < result.Total + return result, nil +} diff --git a/internal/creator/comment_aggregation_test.go b/internal/creator/comment_aggregation_test.go new file mode 100644 index 0000000..886fbf2 --- /dev/null +++ b/internal/creator/comment_aggregation_test.go @@ -0,0 +1,117 @@ +package creator + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func TestBuildCommentAggregationQuery(t *testing.T) { + now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.FixedZone("local", 8*3600)) + for _, source := range []string{SourceOwned, SourceCompetitor} { + for _, hours := range []int{1, 6, 12, 24, 72, 120, 168} { + query, args, err := buildCommentAggregationQuery(CommentAggregationFilter{SourceType: source, SourceID: "account-1", Hours: hours}, now) + if err != nil { + t.Fatal(err) + } + for _, clause := range []string{"w.source_type = $1", "c.published_at >= $2", "c.published_at <= $3", "w.source_id = $4", "JOIN creator_work w ON w.id = c.work_id"} { + if !strings.Contains(query, clause) { + t.Fatalf("missing %q in %s", clause, query) + } + } + if !reflect.DeepEqual(args, []any{source, now.UTC().Add(-time.Duration(hours) * time.Hour), now.UTC(), "account-1"}) { + t.Fatalf("args = %#v", args) + } + } + } + query, args, err := buildCommentAggregationQuery(CommentAggregationFilter{SourceType: SourceOwned, Hours: 24}, now) + if err != nil || len(args) != 3 || strings.Contains(query, "w.source_id =") { + t.Fatalf("all accounts query=%s args=%v err=%v", query, args, err) + } + for _, filter := range []CommentAggregationFilter{{Hours: 24}, {SourceType: "other", Hours: 24}, {SourceType: SourceOwned}, {SourceType: SourceOwned, Hours: -1}, {SourceType: SourceOwned, Hours: 2}, {SourceType: SourceOwned, Hours: 169}} { + if _, _, err := buildCommentAggregationQuery(filter, now); !errors.Is(err, ErrInvalid) { + t.Fatalf("filter=%+v err=%v", filter, err) + } + } +} + +func TestCommentAggregationPageIntegration(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + now := time.Now().UTC().Truncate(time.Second) + ownedID := createIntegrationAccount(t, ctx, accounts, "comments-owned") + competitor, err := store.UpsertCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "comments-competitor", HomepageURL: "https://www.douyin.com/user/comments-competitor", Nickname: "竞品昵称"}) + if err != nil { + t.Fatal(err) + } + addWork := func(key, source, id string) Work { + work, _, err := store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: key, SourceType: source, SourceID: id, Title: key, OriginalURL: "https://www.douyin.com/video/" + key}, now) + if err != nil { + t.Fatal(err) + } + return work + } + owned := addWork("owned-comments", SourceOwned, ownedID) + rival := addWork("competitor-comments", SourceCompetitor, competitor.ID) + addComment := func(work Work, key string, at *time.Time) Comment { + comment, _, err := store.SaveComment(ctx, CommentInput{Platform: PlatformDouyin, WorkID: work.ID, CommentKey: key, AuthorUID: "reader-uid", AuthorName: "评论者", Content: key, PublishedAt: at, CommentType: "top_level"}) + if err != nil { + t.Fatal(err) + } + return comment + } + newest := addComment(owned, "newest", &now) + boundary := now.Add(-time.Hour) + addComment(owned, "boundary", &boundary) + addComment(owned, "same-time", &boundary) + addComment(owned, "unknown", nil) + future := now.Add(time.Second) + addComment(owned, "future", &future) + for _, hours := range []int{6, 12, 24, 72, 120, 168} { + at := now.Add(-time.Duration(hours) * time.Hour) + addComment(owned, "age-"+at.Format(time.RFC3339), &at) + } + old := now.Add(-168*time.Hour - time.Second) + addComment(owned, "old", &old) + rivalComment := addComment(rival, "rival", &now) + for index, hours := range []int{1, 6, 12, 24, 72, 120, 168} { + page, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: hours}, 1, 100, now) + if err != nil || page.Total != 3+index || len(page.Data) != 3+index { + t.Fatalf("hours=%d page=%+v err=%v", hours, page, err) + } + if page.Data[0].ID != newest.ID || page.Data[0].SourceID != ownedID || page.Data[0].SourceType != SourceOwned || page.Data[0].SourceName == "" || page.Data[0].SourceAccountKey == "" || page.Data[0].WorkTitle != owned.Title || page.Data[0].WorkURL != owned.OriginalURL || page.Data[0].AuthorUID != "reader-uid" { + t.Fatalf("metadata=%+v", page.Data[0]) + } + } + first, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, SourceID: ownedID, Hours: 1}, 1, 2, now) + if err != nil || first.Total != 3 || len(first.Data) != 2 || !first.HasNext { + t.Fatalf("first=%+v err=%v", first, err) + } + second, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, 2, 2, now) + if err != nil || second.Total != 3 || len(second.Data) != 1 || second.HasNext || second.Data[0].ID == first.Data[1].ID { + t.Fatalf("second=%+v err=%v", second, err) + } + for _, filter := range []CommentAggregationFilter{{SourceType: SourceOwned, SourceID: competitor.ID, Hours: 1}, {SourceType: SourceCompetitor, SourceID: ownedID, Hours: 1}} { + page, err := store.ListCommentAggregationPage(ctx, filter, 1, 25, now) + if err != nil || page.Total != 0 || page.Data == nil || len(page.Data) != 0 { + t.Fatalf("cross source=%+v err=%v", page, err) + } + } + page, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceCompetitor, Hours: 1}, 1, 25, now) + if err != nil || page.Total != 1 || page.Data[0].ID != rivalComment.ID || page.Data[0].SourceName != "竞品昵称" || page.Data[0].SourceAccountKey != "comments-competitor" { + t.Fatalf("competitor=%+v err=%v", page, err) + } + page, err = store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, 10, 25, now) + if err != nil || page.Total != 3 || page.Data == nil || len(page.Data) != 0 { + t.Fatalf("out of range=%+v err=%v", page, err) + } + for _, pagination := range [][2]int{{0, 25}, {1, 0}, {1, 101}} { + if _, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, pagination[0], pagination[1], now); !errors.Is(err, ErrInvalid) { + t.Fatalf("pagination=%v err=%v", pagination, err) + } + } + if _, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: "invalid", Hours: 1}, 1, 25, now); !errors.Is(err, ErrInvalid) { + t.Fatal(err) + } +} diff --git a/web/src/pages/creator/comments/index.tsx b/web/src/pages/creator/comments/index.tsx new file mode 100644 index 0000000..e6f61d5 --- /dev/null +++ b/web/src/pages/creator/comments/index.tsx @@ -0,0 +1,310 @@ +import { ReloadOutlined } from "@ant-design/icons"; +import { + Alert, + Button, + Card, + Flex, + Form, + Select, + Table, + Tabs, + Typography, +} from "antd"; +import type { TableColumnsType } from "antd"; +import { useEffect, useState } from "react"; +import { creatorGet, getList } from "@/services/api"; +import { conflictMessage } from "@/utils/helpers"; + +type SourceType = "owned" | "competitor"; +type Account = { + id: string; + name?: string; + nickname?: string; + platform_account_key?: string; +}; +type AggregatedComment = { + id: string; + author_uid: string; + author_name: string; + content: string; + published_at: string; + source_id: string; + source_name: string; + source_account_key: string; + work_key: string; + work_title: string; + work_url: string; +}; +const timeOptions = [ + { value: 1, label: "最近 1 小时" }, + { value: 6, label: "最近 6 小时" }, + { value: 12, label: "最近 12 小时" }, + { value: 24, label: "最近 1 天" }, + { value: 72, label: "最近 3 天" }, + { value: 120, label: "最近 5 天" }, + { value: 168, label: "最近 7 天" }, +]; +const columns: TableColumnsType = [ + { + title: "评论发布时间", + dataIndex: "published_at", + width: 180, + render: (value: string) => + new Date(value).toLocaleString("zh-CN", { hour12: false }), + }, + { + title: "评论者", + width: 180, + render: (_, item) => ( + + + {item.author_name || item.author_uid || "—"} + + {item.author_uid && ( + {`UID:${item.author_uid}`} + )} + + ), + }, + { title: "评论内容", dataIndex: "content", width: 320 }, + { + title: "所属账号", + width: 200, + render: (_, item) => ( + + + {item.source_name || item.source_account_key || item.source_id} + + {item.source_account_key && ( + {`UID:${item.source_account_key}`} + )} + + ), + }, + { + title: "对应作品", + width: 240, + render: (_, item) => + item.work_url ? ( + + {item.work_title || item.work_key} + + ) : ( + {item.work_title || item.work_key} + ), + }, +]; + +export function CommentAggregationTab({ + sourceType, +}: { + sourceType: SourceType; +}) { + const [filters, setFilters] = useState<{ sourceId?: string; hours: number }>({ + hours: 24, + }); + const [pagination, setPagination] = useState({ page: 1, pageSize: 25 }); + const [comments, setComments] = useState([]); + const [total, setTotal] = useState(0); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [accounts, setAccounts] = useState([]); + const [accountsPending, setAccountsPending] = useState(true); + const [accountsError, setAccountsError] = useState(null); + const [revision, setRevision] = useState(0); + + useEffect(() => { + let active = true; + const endpoint = + sourceType === "owned" ? "/creator/accounts" : "/creator/competitors"; + setAccountsPending(true); + setAccountsError(null); + creatorGet(endpoint) + .then((result: Account[]) => { + if (active) setAccounts(result); + }) + .catch((cause) => { + console.error("读取评论聚合账号失败", { + sourceType, + endpoint, + error: cause, + }); + if (active) setAccountsError(cause); + }) + .finally(() => { + if (active) setAccountsPending(false); + }); + return () => { + active = false; + }; + }, [sourceType, revision]); + + useEffect(() => { + let active = true; + const query = { + page: pagination.page, + pageSize: pagination.pageSize, + filters: { + source_type: sourceType, + source_id: filters.sourceId, + hours: filters.hours, + }, + }; + setPending(true); + setError(null); + setComments([]); + setTotal(0); + getList({ + resource: "creator-comment-aggregation", + ...query, + }) + .then((result) => { + if (active) { + setComments(result.data); + setTotal(result.total); + } + }) + .catch((cause) => { + console.error("读取评论聚合列表失败", { ...query, error: cause }); + if (active) setError(cause); + }) + .finally(() => { + if (active) setPending(false); + }); + return () => { + active = false; + }; + }, [ + sourceType, + filters.sourceId, + filters.hours, + pagination.page, + pagination.pageSize, + revision, + ]); + + const changeFilters = (next: { sourceId?: string; hours: number }) => { + setFilters(next); + setPagination((current) => ({ ...current, page: 1 })); + }; + return ( + + + + {pending ? "正在读取评论…" : `共 ${total} 条评论`} + + + + +
+ + changeFilters({ ...filters, hours })} + /> + + + + +
+ + 按评论发布时间筛选,仅展示已采集评论;未记录发布时间的评论不在结果中。最新评论在前。 + + {Boolean(accountsError) && ( + + )} + {Boolean(error) && ( + + )} + + rowKey="id" + columns={columns} + dataSource={comments} + loading={pending} + scroll={{ x: 1120 }} + locale={{ + emptyText: error + ? "评论读取失败,请点击刷新重试" + : "所选时间内暂无已采集评论", + }} + pagination={{ + current: pagination.page, + pageSize: pagination.pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [25, 50, 100], + }} + onChange={(next) => + setPagination((current) => ({ + page: + next.pageSize !== current.pageSize ? 1 : (next.current ?? 1), + pageSize: next.pageSize ?? current.pageSize, + })) + } + /> +
+
+ ); +} + +export default function CommentAggregationPage() { + return ( + , + }, + { + key: "competitor", + label: "竞品作品评论", + children: , + }, + ]} + /> + ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f57e7ad..484821a 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -11,6 +11,7 @@ export const resourcePaths = { 'creator-competitor-share-jobs': '/creator/competitor-share-jobs', 'creator-works': '/creator/works', 'creator-comments': '/creator/comments', + 'creator-comment-aggregation': '/creator/comments/aggregation', 'creator-leads': '/creator/leads', 'creator-rules': '/creator/rules', } as const; @@ -23,6 +24,7 @@ const filterKeys: Partial> = { 'creator-competitor-share-jobs': ['platform', 'status'], 'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'], 'creator-comments': ['platform', 'work_id'], + 'creator-comment-aggregation': ['source_type', 'source_id', 'hours'], 'creator-leads': ['platform'], }; diff --git a/web/src/utils/metadata.tsx b/web/src/utils/metadata.tsx index 4e58b91..c6e693a 100644 --- a/web/src/utils/metadata.tsx +++ b/web/src/utils/metadata.tsx @@ -22,6 +22,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s { test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' }, { test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' }, { test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' }, + { test: (p) => p === '/creator/comments', title: '评论聚合', subtitle: '分别查看我的作品与竞品作品的已采集评论,按所属账号和评论发布时间筛选。' }, { test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' }, { test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' }, { test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' }, @@ -40,6 +41,7 @@ export const menu: MenuDataItem[] = [ { path: '/accounts', name: '我的账号', icon: }, { path: '/accounts/monitoring', name: '监控账号', icon: }, { path: '/creator/competitors', name: '作品分析', icon: }, + { path: '/creator/comments', name: '评论聚合', icon: }, { path: '/creator/settings', name: '采集设置', icon: }, ], }, diff --git a/web/tests/comment-aggregation-api.test.cjs b/web/tests/comment-aggregation-api.test.cjs new file mode 100644 index 0000000..cd95904 --- /dev/null +++ b/web/tests/comment-aggregation-api.test.cjs @@ -0,0 +1,80 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const Module = require("node:module"); +const path = require("node:path"); +const test = require("node:test"); +const ts = require("../node_modules/typescript"); +const target = path.resolve(__dirname, "../src/services/api.ts"); +let response; +const calls = []; +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (parent?.filename === target && request === "@/requestErrorConfig") + return { + request: async (url) => { + calls.push(url); + return response; + }, + }; + return originalLoad.call(this, request, parent, isMain); +}; +const source = ts.transpileModule(fs.readFileSync(target, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const mod = new Module(target, module); +mod.filename = target; +mod.paths = module.paths; +mod._compile(source, target); +const { getList, resourcePaths } = mod.exports; + +test("comment aggregation transmits source, account, hours and pagination to dedicated endpoint", async () => { + response = { data: [{ id: "comment-1" }], total: 201, has_next: true }; + const result = await getList({ + resource: "creator-comment-aggregation", + page: 3, + pageSize: 25, + filters: { + source_type: "owned", + source_id: "account/1", + hours: 6, + unsupported: "ignored", + }, + }); + const query = new URL(calls.at(-1), "https://example.test"); + assert.equal(query.pathname, "/creator/comments/aggregation"); + assert.equal(query.searchParams.get("source_type"), "owned"); + assert.equal(query.searchParams.get("source_id"), "account/1"); + assert.equal(query.searchParams.get("hours"), "6"); + assert.equal(query.searchParams.get("page"), "3"); + assert.equal(query.searchParams.get("page_size"), "25"); + assert.equal(query.searchParams.has("unsupported"), false); + assert.deepEqual(result, { + data: [{ id: "comment-1" }], + total: 201, + hasNext: true, + }); + response = { data: [], total: 0, has_next: false }; + assert.deepEqual( + await getList({ + resource: "creator-comment-aggregation", + page: 1, + pageSize: 25, + filters: { source_type: "competitor", source_id: undefined, hours: 24 }, + }), + { data: [], total: 0, hasNext: false }, + ); + assert.equal( + new URL(calls.at(-1), "https://example.test").searchParams.has("source_id"), + false, + ); + assert.equal(resourcePaths["creator-comments"], "/creator/comments"); +}); + +test("menu and page header expose comment aggregation", () => { + const metadata = fs.readFileSync( + path.resolve(__dirname, "../src/utils/metadata.tsx"), + "utf8", + ); + assert.match(metadata, /path: '\/creator\/comments', name: '评论聚合'/); + assert.match(metadata, /p === '\/creator\/comments', title: '评论聚合'/); +}); diff --git a/web/tests/comment-aggregation.test.cjs b/web/tests/comment-aggregation.test.cjs new file mode 100644 index 0000000..7a9b252 --- /dev/null +++ b/web/tests/comment-aggregation.test.cjs @@ -0,0 +1,418 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const Module = require("node:module"); +const path = require("node:path"); +const test = require("node:test"); +const ts = require("../node_modules/typescript"); + +const target = path.resolve( + __dirname, + "../src/pages/creator/comments/index.tsx", +); +const originalLoad = Module._load; +const createElement = (type, props, ...children) => ({ + type, + props: { ...props, children: children.length === 1 ? children[0] : children }, +}); +const names = [ + "Alert", + "Button", + "Card", + "Flex", + "Form", + "Select", + "Table", + "Tabs", + "Typography", +]; +const components = Object.fromEntries(names.map((name) => [name, name])); +components.Form = { Item: "Form.Item" }; +components.Typography = { Text: "Text", Link: "Link" }; +const same = (a, b) => + a && + b && + a.length === b.length && + a.every((value, index) => Object.is(value, b[index])); +let harness; +Module._load = function (request, parent, isMain) { + if (parent?.filename === target) { + if (request === "react") return harness.react; + if (request === "antd") return components; + if (request === "@ant-design/icons") + return { ReloadOutlined: "ReloadOutlined" }; + if (request === "@/services/api") + return { + getList: ({ resource, ...query }) => + harness.listResource(resource, query), + creatorGet: (...args) => harness.creatorGet(...args), + }; + if (request === "@/utils/helpers") + return { + conflictMessage: (error, fallback) => error?.message || fallback, + }; + } + return originalLoad.call(this, request, parent, isMain); +}; +function load() { + const source = ts.transpileModule( + "import React from 'react';\n" + fs.readFileSync(target, "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + }, + }, + ).outputText; + const mod = new Module(target, module); + mod.filename = target; + mod.paths = module.paths; + mod._compile(source, target); + return mod.exports; +} +function makeHarness() { + const state = []; + const effects = []; + const pending = []; + const calls = []; + const queries = []; + let cursor = 0; + const h = { + state, + effects, + calls, + queries, + react: { + createElement, + useState(initial) { + const index = cursor++; + if (!(index in state)) state[index] = initial; + return [ + state[index], + (value) => { + state[index] = + typeof value === "function" ? value(state[index]) : value; + }, + ]; + }, + useEffect(fn, deps) { + const index = cursor++; + if (!same(effects[index]?.deps, deps)) + pending.push(() => { + effects[index]?.cleanup?.(); + effects[index] = { deps, cleanup: fn() }; + }); + }, + }, + creatorGet: async () => [ + { + id: "account-1", + name: "我的账号", + nickname: "竞品账号", + platform_account_key: "uid-1", + }, + ], + listResource: async (resource, query) => { + calls.push(resource); + queries.push(query); + return { data: [], total: 0 }; + }, + render(Component, props) { + cursor = 0; + const tree = Component(props); + while (pending.length) pending.shift()(); + return tree; + }, + cleanup() { + effects.forEach((effect) => effect?.cleanup?.()); + }, + }; + harness = h; + return h; +} +const tick = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; +function nodes(tree, type) { + const matches = []; + function walk(node) { + if (!node) return; + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (typeof node !== "object") return; + if (node.type === type) matches.push(node); + walk(node.props?.children); + } + walk(tree); + return matches; +} +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +test("two independently retained tabs and seven relative publication windows", async () => { + const h = makeHarness(); + const page = load(); + const root = page.default(); + const tabs = nodes(root, "Tabs")[0]; + assert.deepEqual( + tabs.props.items.map((item) => [item.key, item.label]), + [ + ["owned", "我的作品评论"], + ["competitor", "竞品作品评论"], + ], + ); + assert.equal(tabs.props.destroyOnHidden, false); + const props = { sourceType: "owned" }; + h.render(page.CommentAggregationTab, props); + await tick(); + const tree = h.render(page.CommentAggregationTab, props); + assert.deepEqual(h.calls, ["creator-comment-aggregation"]); + assert.deepEqual(h.queries[0], { + page: 1, + pageSize: 25, + filters: { source_type: "owned", source_id: undefined, hours: 24 }, + }); + const select = nodes(tree, "Select").find( + (node) => node.props["aria-label"] === "评论发布时间", + ); + assert.deepEqual( + select.props.options.map((option) => option.value), + [1, 6, 12, 24, 72, 120, 168], + ); + const accounts = nodes(tree, "Select").find( + (node) => node.props["aria-label"] === "所属账号", + ); + assert.deepEqual(accounts.props.options, [ + { value: "account-1", label: "我的账号(uid-1)" }, + ]); + h.cleanup(); +}); + +test("account and time changes reset pagination and preserve page size", async () => { + const h = makeHarness(); + const page = load(); + const props = { sourceType: "competitor" }; + h.render(page.CommentAggregationTab, props); + await tick(); + let tree = h.render(page.CommentAggregationTab, props); + nodes(tree, "Table")[0].props.onChange({ current: 1, pageSize: 50 }); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + nodes(tree, "Table")[0].props.onChange({ current: 3, pageSize: 50 }); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(h.queries.at(-1).page, 3); + assert.equal(h.queries.at(-1).pageSize, 50); + nodes(tree, "Select") + .find((node) => node.props["aria-label"] === "所属账号") + .props.onChange("account-1"); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.deepEqual(h.queries.at(-1), { + page: 1, + pageSize: 50, + filters: { source_type: "competitor", source_id: "account-1", hours: 24 }, + }); + nodes(tree, "Select") + .find((node) => node.props["aria-label"] === "评论发布时间") + .props.onChange(6); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(h.queries.at(-1).filters.hours, 6); + assert.equal(h.queries.at(-1).page, 1); + nodes(tree, "Button") + .find((node) => node.props.children === "重置") + .props.onClick(); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(h.queries.at(-1).filters.hours, 24); + assert.equal(h.queries.at(-1).filters.source_id, undefined); + nodes(tree, "Table")[0].props.onChange({ current: 2, pageSize: 25 }); + h.render(page.CommentAggregationTab, props); + await tick(); + assert.equal(h.queries.at(-1).page, 1); + assert.equal(h.queries.at(-1).pageSize, 25); + h.cleanup(); +}); + +test("renders commenter, account and work metadata with server pagination", async () => { + const h = makeHarness(); + const page = load(); + const props = { sourceType: "owned" }; + const item = { + id: "comment-1", + content: "评论内容", + author_name: "读者", + author_uid: "reader-uid", + published_at: "2026-09-20T04:00:00Z", + source_name: "账号", + source_account_key: "uid-1", + source_id: "account-1", + work_title: "作品标题", + work_key: "work-key", + work_url: "https://www.douyin.com/video/work-key", + }; + h.listResource = async () => ({ data: [item], total: 101 }); + h.render(page.CommentAggregationTab, props); + await tick(); + const tree = h.render(page.CommentAggregationTab, props); + const table = nodes(tree, "Table")[0]; + assert.deepEqual(table.props.dataSource, [item]); + assert.equal(table.props.pagination.total, 101); + assert.equal(table.props.pagination.current, 1); + assert.deepEqual( + table.props.columns.map((column) => column.title), + ["评论发布时间", "评论者", "评论内容", "所属账号", "对应作品"], + ); + const account = table.props.columns + .find((column) => column.title === "所属账号") + .render(undefined, item); + assert.equal(nodes(account, "Text")[0].props.children, "账号"); + assert.equal(nodes(account, "Text")[1].props.children, "UID:uid-1"); + const work = table.props.columns + .find((column) => column.title === "对应作品") + .render(undefined, item); + assert.equal(nodes(work, "Link")[0].props.href, item.work_url); + assert.equal(nodes(work, "Link")[0].props.children, item.work_title); + const author = table.props.columns + .find((column) => column.title === "评论者") + .render(undefined, item); + assert.equal(nodes(author, "Text")[1].props.children, "UID:reader-uid"); + const empty = { + ...item, + source_name: "", + source_account_key: "", + author_name: "", + author_uid: "", + work_title: "", + work_url: "", + }; + assert.equal( + nodes( + table.props.columns + .find((column) => column.title === "所属账号") + .render(undefined, empty), + "Text", + )[0].props.children, + "account-1", + ); + assert.equal( + nodes( + table.props.columns + .find((column) => column.title === "对应作品") + .render(undefined, empty), + "Text", + )[0].props.children, + "work-key", + ); + h.cleanup(); +}); + +test("obsolete responses do not replace current filters and refresh retries errors", async () => { + const h = makeHarness(); + const page = load(); + const props = { sourceType: "owned" }; + const old = deferred(); + const fresh = deferred(); + let count = 0; + h.listResource = () => (++count === 1 ? old.promise : fresh.promise); + h.render(page.CommentAggregationTab, props); + await tick(); + let tree = h.render(page.CommentAggregationTab, props); + nodes(tree, "Select") + .find((node) => node.props["aria-label"] === "评论发布时间") + .props.onChange(1); + h.render(page.CommentAggregationTab, props); + fresh.resolve({ data: [{ id: "fresh" }], total: 1 }); + await tick(); + old.resolve({ data: [{ id: "old" }], total: 9 }); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [{ id: "fresh" }]); + assert.equal(nodes(tree, "Table")[0].props.pagination.total, 1); + const errors = []; + const previous = console.error; + console.error = (...args) => errors.push(args); + try { + h.listResource = async () => { + throw new Error("读取失败"); + }; + nodes(tree, "Button") + .find((node) => node.props.children === "刷新") + .props.onClick(); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(nodes(tree, "Alert")[0].props.title, "评论读取失败"); + assert.equal(nodes(tree, "Alert")[0].props.description, "读取失败"); + assert.equal(errors.length, 1); + assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, []); + h.listResource = async () => ({ data: [{ id: "retried" }], total: 1 }); + nodes(tree, "Button") + .find((node) => node.props.children === "刷新") + .props.onClick(); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(nodes(tree, "Alert").length, 0); + assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [ + { id: "retried" }, + ]); + } finally { + console.error = previous; + h.cleanup(); + } +}); + +test("account errors remain visible and disable account filter until retry", async () => { + const h = makeHarness(); + const page = load(); + const props = { sourceType: "competitor" }; + const previous = console.error; + console.error = () => {}; + try { + h.creatorGet = async () => { + throw new Error("账号接口失败"); + }; + h.render(page.CommentAggregationTab, props); + await tick(); + let tree = h.render(page.CommentAggregationTab, props); + assert.equal(nodes(tree, "Alert")[0].props.title, "账号读取失败"); + assert.equal( + nodes(tree, "Select").find( + (node) => node.props["aria-label"] === "所属账号", + ).props.disabled, + true, + ); + h.creatorGet = async () => []; + nodes(tree, "Button") + .find((node) => node.props.children === "刷新") + .props.onClick(); + h.render(page.CommentAggregationTab, props); + await tick(); + tree = h.render(page.CommentAggregationTab, props); + assert.equal(nodes(tree, "Alert").length, 0); + assert.equal( + nodes(tree, "Select").find( + (node) => node.props["aria-label"] === "所属账号", + ).props.disabled, + false, + ); + } finally { + console.error = previous; + h.cleanup(); + } +});