diff --git a/CLAUDE.md b/CLAUDE.md index 06d9b67..1137de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -507,5 +507,11 @@ export async function queryVideos(request: QueryRequest): Promise ## 前端常见问题 - **Next.js 模块错误**:清理缓存 `rm -rf .next node_modules/.cache && pnpm build` -- **Ant Design Modal 文字无法复制**:添加 `styles={{ body: { userSelect: 'text' } }}` +- **Ant Design Modal 文字无法复制**:需要多层修复: + 1. Modal: `styles={{ body: { userSelect: 'text' } }}` + 2. Descriptions: 添加 `contentStyle={{ userSelect: 'text', cursor: 'text' }}` + 3. globals.css: 添加 `.ant-descriptions td * { user-select: text !important; }` +- **Next.js layout.tsx 不能加 'use client'**:因为导出 metadata,需创建单独的 Provider 组件(如 AntdProvider.tsx) +- **Ant Design v6 ConfigProvider**:v6 不支持 `theme.cssVar` 和 `theme.hashed`,直接用 `` 即可 +- **前端性能优化**:使用 `useMemo` 包裹 columns、`useCallback` 包裹事件处理器、`memo` 包裹子组件 - **CORS 400 错误**:检查后端 `CORSMiddleware` 配置的 `allow_origins` diff --git a/backend/tests/test_yuntu_api_params.py b/backend/tests/test_yuntu_api_params.py new file mode 100644 index 0000000..0e92c48 --- /dev/null +++ b/backend/tests/test_yuntu_api_params.py @@ -0,0 +1,316 @@ +""" +Tests for Yuntu API Parameter Format (T-027) + +根据 doc/temp 的正确格式: +1. 日期格式: YYYYMMDD (如 20251014),不是 YYYY-MM-DD +2. Cookie 头: 直接使用 auth_token 完整值 +3. industry_id: 字符串格式 ["20"],不是整数 +4. Cookie 获取: 随机选取任意一组 aadvid/auth_token +""" + +import pytest +from datetime import datetime, date +from unittest.mock import AsyncMock, patch, MagicMock +import httpx + + +class TestYuntuAPIParameterFormat: + """验证 API 调用参数格式正确性 (T-027)""" + + async def test_date_format_yyyymmdd(self): + """日期格式必须为 YYYYMMDD,不是 YYYY-MM-DD""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {"a3_increase_cnt": "100"}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 10, 14), + industry_id="12", + aadvid="1648829117232140", + auth_token="sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b", + ) + + call_args = mock_client.post.call_args + json_data = call_args.kwargs["json"] + + # 关键验证:日期格式是 YYYYMMDD + assert json_data["start_date"] == "20251014", f"Expected '20251014', got '{json_data['start_date']}'" + assert json_data["end_date"] == "20251113", f"Expected '20251113', got '{json_data['end_date']}'" + + async def test_cookie_header_uses_auth_token_directly(self): + """Cookie 头应直接使用 auth_token 完整值""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + auth_token = "sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b" + + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 10, 14), + industry_id="12", + aadvid="1648829117232140", + auth_token=auth_token, + ) + + call_args = mock_client.post.call_args + headers = call_args.kwargs["headers"] + + # 关键验证:Cookie 直接使用 auth_token 完整值 + assert headers["Cookie"] == auth_token, f"Expected Cookie='{auth_token}', got '{headers['Cookie']}'" + + async def test_industry_id_as_string_array(self): + """industry_id_list 应为字符串数组 ["12"],不是整数""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 10, 14), + industry_id="12", # 字符串 + aadvid="1648829117232140", + auth_token="sessionid=xxx", + ) + + call_args = mock_client.post.call_args + json_data = call_args.kwargs["json"] + + # 关键验证:industry_id_list 是字符串数组 + assert json_data["industry_id_list"] == ["12"], f"Expected ['12'], got {json_data['industry_id_list']}" + + async def test_url_contains_aadvid(self): + """URL 必须包含 aadvid 参数""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + aadvid = "1648829117232140" + + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 10, 14), + industry_id="12", + aadvid=aadvid, + auth_token="sessionid=xxx", + ) + + call_args = mock_client.post.call_args + url = call_args.args[0] + + # 关键验证:URL 包含 aadvid + assert f"aadvid={aadvid}" in url, f"URL should contain 'aadvid={aadvid}', got '{url}'" + + async def test_fixed_parameters(self): + """验证固定参数值正确""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 10, 14), + industry_id="12", + aadvid="1648829117232140", + auth_token="sessionid=xxx", + ) + + call_args = mock_client.post.call_args + json_data = call_args.kwargs["json"] + + # 验证固定参数 + assert json_data["is_my_video"] == "0" + assert json_data["object_type"] == 2 + assert json_data["assist_type"] == 3 + assert json_data["assist_video_type"] == 3 + assert json_data["trigger_point_id_list"] == ["610000", "610300", "610301"] + + async def test_end_date_is_start_plus_30_days(self): + """end_date 应为 start_date + 30 天""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": 0, "data": {}} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + # 测试日期:2025-01-15,+30天 = 2025-02-14 + with patch("httpx.AsyncClient", return_value=mock_client): + await call_yuntu_api( + item_id="video_001", + publish_time=datetime(2025, 1, 15), + industry_id="12", + aadvid="123", + auth_token="sessionid=xxx", + ) + + call_args = mock_client.post.call_args + json_data = call_args.kwargs["json"] + + assert json_data["start_date"] == "20250115" + assert json_data["end_date"] == "20250214" + + async def test_parse_a3_metrics_as_strings(self): + """API 返回的 A3 指标是字符串类型,需正确解析""" + from app.services.yuntu_api import parse_analysis_response + + # 实际 API 响应示例(A3 是字符串) + response = { + "status": 0, + "msg": "ok", + "data": { + "object_id": "7560751618711457062", + "cost": 785000, + "ad_a3_increase_cnt": "36902", + "natural_a3_increase_cnt": "1652169", + "a3_increase_cnt": "1689071", + } + } + + result = parse_analysis_response(response) + + # 解析后应转为整数 + assert result["a3_increase_cnt"] == 1689071 + assert result["ad_a3_increase_cnt"] == 36902 + assert result["natural_a3_increase_cnt"] == 1652169 + assert result["cost"] == 785000 + + +class TestSessionPoolRandomSelection: + """验证 Cookie 池随机选取逻辑 (T-027)""" + + async def test_get_random_config(self): + """应随机选取任意一组配置,不按 brand_id 匹配""" + from app.services.session_pool import SessionPool, CookieConfig + + pool = SessionPool() + + # 模拟刷新后的数据 + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=aaa", + industry_id=20, + brand_name="Test1", + ), + CookieConfig( + brand_id="10186612", + aadvid="1234567890", + auth_token="sessionid=bbb", + industry_id=30, + brand_name="Test2", + ), + ] + + # 调用随机获取 + config = pool.get_random_config() + + # 应返回一个有效配置 + assert config is not None + assert "aadvid" in config + assert "auth_token" in config + + +class TestIntegrationWithRealFormat: + """集成测试:验证完整调用流程""" + + async def test_full_api_call_format(self): + """完整验证 API 调用格式""" + from app.services.yuntu_api import call_yuntu_api + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": 0, + "msg": "ok", + "data": { + "object_id": "7560751618711457062", + "cost": 785000, + "ad_a3_increase_cnt": "36902", + "natural_a3_increase_cnt": "1652169", + "a3_increase_cnt": "1689071", + } + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await call_yuntu_api( + item_id="7560751618711457062", + publish_time=datetime(2025, 10, 14), + industry_id="12", + aadvid="1648829117232140", + auth_token="sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b", + ) + + # 验证调用参数 + call_args = mock_client.post.call_args + url = call_args.args[0] + json_data = call_args.kwargs["json"] + headers = call_args.kwargs["headers"] + + # 1. URL 包含 aadvid + assert "aadvid=1648829117232140" in url + + # 2. 日期格式 YYYYMMDD + assert json_data["start_date"] == "20251014" + assert json_data["end_date"] == "20251113" + + # 3. industry_id 字符串数组 + assert json_data["industry_id_list"] == ["12"] + + # 4. Cookie 直接使用 auth_token + assert headers["Cookie"] == "sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b" + + # 验证返回结果 + assert result["status"] == 0 + assert result["data"]["a3_increase_cnt"] == "1689071" diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 13d40b8..18e10b3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -25,3 +25,35 @@ body { text-wrap: balance; } } + +/* Ant Design Modal 内容可复制 */ +.ant-modal-body { + user-select: text !important; + -webkit-user-select: text !important; +} + +.ant-modal-body * { + user-select: text !important; + -webkit-user-select: text !important; +} + +/* Descriptions 所有内容可复制 - Ant Design v6 兼容 */ +.ant-descriptions-item-content, +.ant-descriptions-item-content *, +.ant-descriptions td, +.ant-descriptions td *, +[class*="ant-descriptions"] td, +[class*="ant-descriptions"] td * { + user-select: text !important; + -webkit-user-select: text !important; + -moz-user-select: text !important; + -ms-user-select: text !important; + cursor: text; +} + +/* 确保表格单元格内容可选 */ +.ant-table-cell, +.ant-table-cell * { + user-select: text !important; + -webkit-user-select: text !important; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index d8e2341..4adc926 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; import './globals.css'; -import { Header, Footer } from '@/components'; +import { Header, Footer, AntdProvider } from '@/components'; const geistSans = localFont({ src: './fonts/GeistVF.woff', @@ -27,11 +27,13 @@ export default function RootLayout({ return ( -
-
-
{children}
-
-
+ +
+
+
{children}
+
+
+
); diff --git a/frontend/src/components/AntdProvider.tsx b/frontend/src/components/AntdProvider.tsx new file mode 100644 index 0000000..c88d4d7 --- /dev/null +++ b/frontend/src/components/AntdProvider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; + +export default function AntdProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/VideoAnalysis.tsx b/frontend/src/components/VideoAnalysis.tsx index a2a0eed..60ffdd0 100644 --- a/frontend/src/components/VideoAnalysis.tsx +++ b/frontend/src/components/VideoAnalysis.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo, useCallback, memo } from 'react'; import { Table, Input, Select, Button, Card, Space, message, Modal, Descriptions, Spin } from 'antd'; import { SearchOutlined, EyeOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; @@ -34,8 +34,8 @@ function formatInt(num: number | null | undefined): string { return Math.round(num).toLocaleString('zh-CN'); } -// 详情弹窗组件 -function DetailModal({ +// 详情弹窗组件 - 使用 memo 避免不必要的重渲染 +const DetailModal = memo(function DetailModal({ visible, data, loading, @@ -53,18 +53,18 @@ function DetailModal({ onCancel={onClose} footer={null} width={900} - styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text' } }} + styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text', WebkitUserSelect: 'text' } }} > {loading ? (
) : data ? ( - + {/* 基础信息 */} - - {data.base_info.star_nickname || '-'} - {data.base_info.star_unique_id || '-'} + + {data.base_info.star_nickname || '-'} + {data.base_info.star_unique_id || '-'} {data.base_info.vid || '-'} {data.base_info.create_date || '-'} {data.base_info.hot_type || '-'} @@ -82,7 +82,7 @@ function DetailModal({ {/* 触达指标 */} - + {formatInt(data.reach_metrics.natural_play_cnt)} {formatInt(data.reach_metrics.heated_play_cnt)} {formatInt(data.reach_metrics.total_play_cnt)} @@ -93,14 +93,14 @@ function DetailModal({ {/* A3指标 */} - + {formatInt(data.a3_metrics.total_new_a3_cnt)} {formatInt(data.a3_metrics.heated_new_a3_cnt)} {formatInt(data.a3_metrics.natural_new_a3_cnt)} {/* 搜索指标 */} - + {formatInt(data.search_metrics.back_search_uv)} {formatInt(data.search_metrics.back_search_cnt)} {formatInt(data.search_metrics.after_view_search_uv)} @@ -109,14 +109,14 @@ function DetailModal({ {/* 费用指标 */} - + {formatNumber(data.cost_metrics.total_cost)} {formatNumber(data.cost_metrics.heated_cost)} {formatNumber(data.cost_metrics.estimated_video_cost)} {/* 成本指标 */} - + {formatNumber(data.calculated_metrics.estimated_cpm)} {formatNumber(data.calculated_metrics.estimated_natural_cpm)} {formatNumber(data.calculated_metrics.estimated_cp_a3)} @@ -128,7 +128,7 @@ function DetailModal({ ) : null} ); -} +}); export default function VideoAnalysis() { const [searchType, setSearchType] = useState('star_id'); @@ -141,7 +141,11 @@ export default function VideoAnalysis() { const [detailLoading, setDetailLoading] = useState(false); const [detailData, setDetailData] = useState(null); - const handleSearch = async () => { + // 详情缓存 - 避免重复请求 + const [detailCache, setDetailCache] = useState>({}); + + // 使用 useCallback 包裹搜索处理器 + const handleSearch = useCallback(async () => { if (!searchValue.trim()) { message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`); return; @@ -167,9 +171,17 @@ export default function VideoAnalysis() { } finally { setLoading(false); } - }; + }, [searchType, searchValue]); + + // 使用 useCallback 包裹详情查看处理器,带缓存逻辑 + const handleViewDetail = useCallback(async (itemId: string) => { + // 检查缓存 + if (detailCache[itemId]) { + setDetailData(detailCache[itemId]); + setDetailVisible(true); + return; + } - const handleViewDetail = async (itemId: string) => { setDetailVisible(true); setDetailLoading(true); setDetailData(null); @@ -178,6 +190,8 @@ export default function VideoAnalysis() { const response = await getVideoAnalysis(itemId); if (response.success) { setDetailData(response.data); + // 缓存结果 + setDetailCache(prev => ({ ...prev, [itemId]: response.data })); } else { message.error(response.error || '获取详情失败'); setDetailVisible(false); @@ -188,10 +202,15 @@ export default function VideoAnalysis() { } finally { setDetailLoading(false); } - }; + }, [detailCache]); - // 表格列定义 - const columns: ColumnsType = [ + // 关闭详情弹窗的回调 + const handleCloseDetail = useCallback(() => { + setDetailVisible(false); + }, []); + + // 使用 useMemo 包裹表格列定义,避免每次重建 + const columns: ColumnsType = useMemo(() => [ { title: '达人昵称', dataIndex: 'star_nickname', @@ -322,7 +341,7 @@ export default function VideoAnalysis() { ), }, - ]; + ], [handleViewDetail]); return (
@@ -357,14 +376,15 @@ export default function VideoAnalysis() { - {/* 结果表格 */} + {/* 结果表格 - 启用虚拟滚动 */} setDetailVisible(false)} + onClose={handleCloseDetail} /> ); diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index c395aed..32827ff 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -3,3 +3,4 @@ export { default as Footer } from './Footer'; export { default as QueryForm } from './QueryForm'; export { default as ResultTable } from './ResultTable'; export { default as ExportButton } from './ExportButton'; +export { default as AntdProvider } from './AntdProvider';