feat: add source-separated comment aggregation page

This commit is contained in:
2026-10-06 14:55:02 +08:00
parent 5691fd1a93
commit b984202ebb
10 changed files with 1184 additions and 0 deletions
@@ -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)
})
}
@@ -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()
}
}
+1
View File
@@ -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 {
+102
View File
@@ -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
}
@@ -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)
}
}
+310
View File
@@ -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<AggregatedComment> = [
{
title: "评论发布时间",
dataIndex: "published_at",
width: 180,
render: (value: string) =>
new Date(value).toLocaleString("zh-CN", { hour12: false }),
},
{
title: "评论者",
width: 180,
render: (_, item) => (
<Flex vertical>
<Typography.Text>
{item.author_name || item.author_uid || "—"}
</Typography.Text>
{item.author_uid && (
<Typography.Text type="secondary">{`UID:${item.author_uid}`}</Typography.Text>
)}
</Flex>
),
},
{ title: "评论内容", dataIndex: "content", width: 320 },
{
title: "所属账号",
width: 200,
render: (_, item) => (
<Flex vertical>
<Typography.Text>
{item.source_name || item.source_account_key || item.source_id}
</Typography.Text>
{item.source_account_key && (
<Typography.Text type="secondary">{`UID:${item.source_account_key}`}</Typography.Text>
)}
</Flex>
),
},
{
title: "对应作品",
width: 240,
render: (_, item) =>
item.work_url ? (
<Typography.Link href={item.work_url} target="_blank" rel="noreferrer">
{item.work_title || item.work_key}
</Typography.Link>
) : (
<Typography.Text>{item.work_title || item.work_key}</Typography.Text>
),
},
];
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<AggregatedComment[]>([]);
const [total, setTotal] = useState(0);
const [pending, setPending] = useState(true);
const [error, setError] = useState<unknown>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [accountsPending, setAccountsPending] = useState(true);
const [accountsError, setAccountsError] = useState<unknown>(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<AggregatedComment>({
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 (
<Card>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text>
{pending ? "正在读取评论…" : `共 ${total} 条评论`}
</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={pending || accountsPending}
onClick={() => setRevision((current) => current + 1)}
>
刷新
</Button>
</Flex>
<Flex vertical gap={16}>
<Form layout="inline">
<Form.Item label="所属账号">
<Select
aria-label="所属账号"
placeholder="全部账号"
allowClear
showSearch={{ optionFilterProp: "label" }}
loading={accountsPending}
disabled={accountsPending || Boolean(accountsError)}
style={{ width: 220 }}
value={filters.sourceId}
options={accounts.map((account) => ({
value: account.id,
label: `${(sourceType === "owned" ? account.name : account.nickname) || account.platform_account_key || account.id}${account.platform_account_key ? `(${account.platform_account_key})` : ""}`,
}))}
onChange={(sourceId) => changeFilters({ ...filters, sourceId })}
/>
</Form.Item>
<Form.Item label="评论发布时间">
<Select
aria-label="评论发布时间"
style={{ width: 160 }}
value={filters.hours}
options={timeOptions}
onChange={(hours) => changeFilters({ ...filters, hours })}
/>
</Form.Item>
<Form.Item>
<Button onClick={() => changeFilters({ hours: 24 })}>重置</Button>
</Form.Item>
</Form>
<Typography.Text type="secondary">
按评论发布时间筛选,仅展示已采集评论;未记录发布时间的评论不在结果中。最新评论在前。
</Typography.Text>
{Boolean(accountsError) && (
<Alert
type="error"
showIcon
title="账号读取失败"
description={conflictMessage(
accountsError,
"无法读取账号,请点击刷新重试。",
)}
/>
)}
{Boolean(error) && (
<Alert
type="error"
showIcon
title="评论读取失败"
description={conflictMessage(
error,
"无法读取评论,请点击刷新重试。",
)}
/>
)}
<Table<AggregatedComment>
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,
}))
}
/>
</Flex>
</Card>
);
}
export default function CommentAggregationPage() {
return (
<Tabs
destroyOnHidden={false}
items={[
{
key: "owned",
label: "我的作品评论",
children: <CommentAggregationTab sourceType="owned" />,
},
{
key: "competitor",
label: "竞品作品评论",
children: <CommentAggregationTab sourceType="competitor" />,
},
]}
/>
);
}
+2
View File
@@ -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<Record<Resource, string[]>> = {
'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'],
};
+2
View File
@@ -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: <UserOutlined /> },
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
{ path: '/creator/comments', name: '评论聚合', icon: <CommentOutlined /> },
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
],
},
@@ -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: '评论聚合'/);
});
+418
View File
@@ -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();
}
});