From 2f24dcfd3452f0d5f7e9c620562a6594c6bcff15 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Feb 2026 14:12:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=84=E5=88=99=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=A2=9E=E5=BC=BA=20=E2=80=94=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=85=A5=20DB=20=E8=A7=84=E5=88=99=20+=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=9B=86=E6=88=90=E6=A3=80=E6=9F=A5=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则, 新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面 添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。 Co-Authored-By: Claude Opus 4.6 --- backend/app/api/rules.py | 69 +++++-- backend/tests/test_rules_api.py | 188 ++++++++++++++++- frontend/app/agency/briefs/[id]/page.tsx | 165 ++++++++++++++- .../app/brand/projects/[id]/config/page.tsx | 195 ++++++++++++++++-- 4 files changed, 580 insertions(+), 37 deletions(-) diff --git a/backend/app/api/rules.py b/backend/app/api/rules.py index 82b47dc..16f885c 100644 --- a/backend/app/api/rules.py +++ b/backend/app/api/rules.py @@ -455,30 +455,67 @@ async def get_platform_rules(platform: str) -> PlatformRuleResponse: # ==================== 规则冲突检测 ==================== @router.post("/validate", response_model=RuleValidateResponse) -async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse: - """检测 Brief 与平台规则冲突""" +async def validate_rules( + request: RuleValidateRequest, + x_tenant_id: str = Header(..., alias="X-Tenant-ID"), + db: AsyncSession = Depends(get_db), +) -> RuleValidateResponse: + """检测 Brief 与平台规则冲突(合并 DB 规则 + 硬编码兜底)""" conflicts = [] - platform_rule = _platform_rules.get(request.platform) - if not platform_rule: - return RuleValidateResponse(conflicts=[]) + # 1. 收集违禁词:DB active 规则优先,硬编码兜底 + db_rules = await get_active_platform_rules( + x_tenant_id, request.brand_id, request.platform, db + ) + forbidden_words: set[str] = set() + min_seconds: Optional[int] = None + max_seconds: Optional[int] = None - # 检查 required_phrases 是否包含违禁词 - required_phrases = request.brief_rules.get("required_phrases", []) - platform_forbidden = [] - for rule in platform_rule.get("rules", []): + if db_rules: + forbidden_words.update(db_rules.get("forbidden_words", [])) + duration = db_rules.get("duration") or {} + min_seconds = duration.get("min_seconds") + max_seconds = duration.get("max_seconds") + + # 硬编码兜底 + hardcoded = _platform_rules.get(request.platform, {}) + for rule in hardcoded.get("rules", []): if rule.get("type") == "forbidden_word": - platform_forbidden.extend(rule.get("words", [])) + forbidden_words.update(rule.get("words", [])) + elif rule.get("type") == "duration" and min_seconds is None: + if rule.get("min_seconds") is not None: + min_seconds = rule["min_seconds"] + if rule.get("max_seconds") is not None and max_seconds is None: + max_seconds = rule["max_seconds"] - for phrase in required_phrases: - for word in platform_forbidden: - if word in phrase: + # 2. 检查卖点/必选短语与违禁词冲突 + phrases = list(request.brief_rules.get("required_phrases", [])) + phrases += list(request.brief_rules.get("selling_points", [])) + for phrase in phrases: + for word in forbidden_words: + if word in str(phrase): conflicts.append(RuleConflict( - brief_rule=f"要求使用:{phrase}", - platform_rule=f"平台禁止:{word}", - suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改", + brief_rule=f"卖点包含:{phrase}", + platform_rule=f"{request.platform} 禁止使用:{word}", + suggestion=f"卖点 '{phrase}' 包含违禁词 '{word}',建议修改表述", )) + # 3. 检查时长冲突 + brief_min = request.brief_rules.get("min_duration") + brief_max = request.brief_rules.get("max_duration") + if min_seconds and brief_max and brief_max < min_seconds: + conflicts.append(RuleConflict( + brief_rule=f"Brief 最长时长:{brief_max}秒", + platform_rule=f"{request.platform} 最短要求:{min_seconds}秒", + suggestion=f"Brief 最长 {brief_max}s 低于平台最短要求 {min_seconds}s,视频可能不达标", + )) + if max_seconds and brief_min and brief_min > max_seconds: + conflicts.append(RuleConflict( + brief_rule=f"Brief 最短时长:{brief_min}秒", + platform_rule=f"{request.platform} 最长限制:{max_seconds}秒", + suggestion=f"Brief 最短 {brief_min}s 超过平台最长限制 {max_seconds}s,建议调整", + )) + return RuleValidateResponse(conflicts=conflicts) diff --git a/backend/tests/test_rules_api.py b/backend/tests/test_rules_api.py index cce77f8..e021ac9 100644 --- a/backend/tests/test_rules_api.py +++ b/backend/tests/test_rules_api.py @@ -345,7 +345,7 @@ class TestRuleConflictDetection: @pytest.mark.asyncio async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str): - """检测 Brief 与平台规则冲突""" + """检测 Brief 与平台规则冲突(required_phrases)""" response = await client.post( "/api/v1/rules/validate", headers={"X-Tenant-ID": tenant_id}, @@ -353,7 +353,7 @@ class TestRuleConflictDetection: "brand_id": brand_id, "platform": "douyin", "brief_rules": { - "required_phrases": ["绝对有效"], # 可能违反平台规则 + "required_phrases": ["绝对有效"], } } ) @@ -386,6 +386,190 @@ class TestRuleConflictDetection: assert "platform_rule" in conflict assert "suggestion" in conflict + @pytest.mark.asyncio + async def test_selling_points_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str): + """selling_points 字段也参与冲突检测""" + response = await client.post( + "/api/v1/rules/validate", + headers={"X-Tenant-ID": tenant_id}, + json={ + "brand_id": brand_id, + "platform": "douyin", + "brief_rules": { + "selling_points": ["100%纯天然成分", "绝对安全"], + } + } + ) + data = response.json() + assert len(data["conflicts"]) >= 2 # "100%" 和 "绝对" 都命中 + + @pytest.mark.asyncio + async def test_no_conflict_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str): + """无冲突时返回空列表""" + response = await client.post( + "/api/v1/rules/validate", + headers={"X-Tenant-ID": tenant_id}, + json={ + "brand_id": brand_id, + "platform": "douyin", + "brief_rules": { + "selling_points": ["温和护肤", "适合敏感肌"], + } + } + ) + data = response.json() + assert data["conflicts"] == [] + + @pytest.mark.asyncio + async def test_duration_conflict_brief_max_below_platform_min(self, client: AsyncClient, tenant_id: str, brand_id: str): + """Brief 最长时长低于平台最短要求""" + response = await client.post( + "/api/v1/rules/validate", + headers={"X-Tenant-ID": tenant_id}, + json={ + "brand_id": brand_id, + "platform": "douyin", # 硬编码 min_seconds=7 + "brief_rules": { + "max_duration": 5, + } + } + ) + data = response.json() + assert len(data["conflicts"]) >= 1 + assert any("时长" in c["brief_rule"] for c in data["conflicts"]) + + @pytest.mark.asyncio + async def test_db_rules_participate_in_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str): + """DB 中 active 的规则参与冲突检测""" + headers = {"X-Tenant-ID": tenant_id} + + # 创建并确认一条包含自定义违禁词的 DB 平台规则 + create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin") + rule_id = create_resp.json()["id"] + + custom_rules = { + "forbidden_words": ["自定义违禁词ABC"], + "restricted_words": [], + "duration": {"min_seconds": 15, "max_seconds": 120}, + "content_requirements": [], + "other_rules": [], + } + await client.put( + f"/api/v1/rules/platform-rules/{rule_id}/confirm", + headers=headers, + json={"parsed_rules": custom_rules}, + ) + + # 验证 DB 违禁词参与检测 + response = await client.post( + "/api/v1/rules/validate", + headers=headers, + json={ + "brand_id": brand_id, + "platform": "douyin", + "brief_rules": { + "selling_points": ["这个自定义违禁词ABC很好"], + } + } + ) + data = response.json() + assert len(data["conflicts"]) >= 1 + assert any("自定义违禁词ABC" in c["suggestion"] for c in data["conflicts"]) + + @pytest.mark.asyncio + async def test_db_duration_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str): + """DB 规则中的时长限制参与检测""" + headers = {"X-Tenant-ID": tenant_id} + + # 创建 DB 规则:max_seconds=60 + create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu") + rule_id = create_resp.json()["id"] + + custom_rules = { + "forbidden_words": [], + "restricted_words": [], + "duration": {"min_seconds": 10, "max_seconds": 60}, + "content_requirements": [], + "other_rules": [], + } + await client.put( + f"/api/v1/rules/platform-rules/{rule_id}/confirm", + headers=headers, + json={"parsed_rules": custom_rules}, + ) + + # Brief 最短时长 90s > 平台最长 60s → 冲突 + response = await client.post( + "/api/v1/rules/validate", + headers=headers, + json={ + "brand_id": brand_id, + "platform": "xiaohongshu", + "brief_rules": { + "min_duration": 90, + } + } + ) + data = response.json() + assert len(data["conflicts"]) >= 1 + assert any("最长限制" in c["platform_rule"] for c in data["conflicts"]) + + @pytest.mark.asyncio + async def test_db_and_hardcoded_rules_merge(self, client: AsyncClient, tenant_id: str, brand_id: str): + """DB 规则与硬编码规则合并检测""" + headers = {"X-Tenant-ID": tenant_id} + + # DB 规则只包含自定义违禁词 + create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin") + rule_id = create_resp.json()["id"] + + await client.put( + f"/api/v1/rules/platform-rules/{rule_id}/confirm", + headers=headers, + json={"parsed_rules": { + "forbidden_words": ["DB专属词"], + "restricted_words": [], + "duration": None, + "content_requirements": [], + "other_rules": [], + }}, + ) + + # selling_points 同时包含 DB 违禁词和硬编码违禁词 + response = await client.post( + "/api/v1/rules/validate", + headers=headers, + json={ + "brand_id": brand_id, + "platform": "douyin", + "brief_rules": { + "selling_points": ["这是DB专属词内容", "最好的选择"], + } + } + ) + data = response.json() + # 应同时检出 DB 违禁词和硬编码违禁词 + suggestions = [c["suggestion"] for c in data["conflicts"]] + assert any("DB专属词" in s for s in suggestions) + assert any("最好" in s for s in suggestions) + + @pytest.mark.asyncio + async def test_unknown_platform_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str): + """未知平台返回空冲突(无硬编码规则,无 DB 规则)""" + response = await client.post( + "/api/v1/rules/validate", + headers={"X-Tenant-ID": tenant_id}, + json={ + "brand_id": brand_id, + "platform": "unknown_platform", + "brief_rules": { + "selling_points": ["最好的产品"], + } + } + ) + data = response.json() + assert data["conflicts"] == [] + # ==================== 品牌方平台规则(文档上传 + AI 解析) ==================== diff --git a/frontend/app/agency/briefs/[id]/page.tsx b/frontend/app/agency/briefs/[id]/page.tsx index ab9749f..940b708 100644 --- a/frontend/app/agency/briefs/[id]/page.tsx +++ b/frontend/app/agency/briefs/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter, useParams } from 'next/navigation' import { useToast } from '@/components/ui/Toast' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' @@ -27,11 +27,13 @@ import { Upload, Trash2, File, - Loader2 + Loader2, + Search } from 'lucide-react' import { getPlatformInfo } from '@/lib/platforms' import { api } from '@/lib/api' -import { USE_MOCK } from '@/contexts/AuthContext' +import { USE_MOCK, useAuth } from '@/contexts/AuthContext' +import type { RuleConflict } from '@/types/rules' import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' import type { ProjectResponse } from '@/types/project' @@ -181,6 +183,7 @@ export default function BriefConfigPage() { const router = useRouter() const params = useParams() const toast = useToast() + const { user } = useAuth() const projectId = params.id as string // 加载状态 @@ -205,6 +208,81 @@ export default function BriefConfigPage() { const [isAIParsing, setIsAIParsing] = useState(false) const [isUploading, setIsUploading] = useState(false) + // 规则冲突检测 + const [isCheckingConflicts, setIsCheckingConflicts] = useState(false) + const [showConflictModal, setShowConflictModal] = useState(false) + const [ruleConflicts, setRuleConflicts] = useState([]) + const [showPlatformSelect, setShowPlatformSelect] = useState(false) + + const platformDropdownRef = useRef(null) + + const platformSelectOptions = [ + { value: 'douyin', label: '抖音' }, + { value: 'xiaohongshu', label: '小红书' }, + { value: 'bilibili', label: 'B站' }, + ] + + // 点击外部关闭平台选择下拉 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) { + setShowPlatformSelect(false) + } + } + if (showPlatformSelect) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showPlatformSelect]) + + const handleCheckConflicts = async (platform: string) => { + setShowPlatformSelect(false) + setIsCheckingConflicts(true) + + if (USE_MOCK) { + await new Promise(resolve => setTimeout(resolve, 1000)) + setRuleConflicts([ + { + brief_rule: '卖点包含:100%纯天然成分', + platform_rule: `${platform} 禁止使用:100%`, + suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述", + }, + { + brief_rule: 'Brief 最长时长:5秒', + platform_rule: `${platform} 最短要求:7秒`, + suggestion: 'Brief 最长 5s 低于平台最短要求 7s,视频可能不达标', + }, + ]) + setShowConflictModal(true) + setIsCheckingConflicts(false) + return + } + + try { + // 代理商角色可能没有 brand_id,从 brandBrief 取关联品牌的 ID + const brandId = user?.brand_id || brandBrief.id || '' + const briefRules: Record = { + selling_points: agencyConfig.sellingPoints.map(sp => sp.content), + } + const result = await api.validateRules({ + brand_id: brandId, + platform, + brief_rules: briefRules, + }) + setRuleConflicts(result.conflicts) + if (result.conflicts.length > 0) { + setShowConflictModal(true) + } else { + toast.success('未发现规则冲突') + } + } catch (err) { + console.error('规则冲突检测失败:', err) + toast.error('规则冲突检测失败') + } finally { + setIsCheckingConflicts(false) + } + } + // 加载数据 const loadData = useCallback(async () => { if (USE_MOCK) { @@ -470,6 +548,39 @@ export default function BriefConfigPage() { {brandBrief.brandName}

+
+ + {showPlatformSelect && ( +
+ {platformSelectOptions.map((opt) => ( + + ))} +
+ )} +
+ + + ) } diff --git a/frontend/app/brand/projects/[id]/config/page.tsx b/frontend/app/brand/projects/[id]/config/page.tsx index d9c33e4..24ecded 100644 --- a/frontend/app/brand/projects/[id]/config/page.tsx +++ b/frontend/app/brand/projects/[id]/config/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter, useParams } from 'next/navigation' import { useToast } from '@/components/ui/Toast' import { Card, CardContent } from '@/components/ui/Card' @@ -20,10 +20,13 @@ import { Upload, ChevronDown, ChevronUp, - Loader2 + Loader2, + Search } from 'lucide-react' +import { Modal } from '@/components/ui/Modal' import { api } from '@/lib/api' -import { USE_MOCK } from '@/contexts/AuthContext' +import { USE_MOCK, useAuth } from '@/contexts/AuthContext' +import type { RuleConflict } from '@/types/rules' import { useOSSUpload } from '@/hooks/useOSSUpload' import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' @@ -109,6 +112,7 @@ export default function ProjectConfigPage() { const router = useRouter() const params = useParams() const toast = useToast() + const { user } = useAuth() const projectId = params.id as string const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general') @@ -133,6 +137,82 @@ export default function ProjectConfigPage() { const [isSaving, setIsSaving] = useState(false) const [activeSection, setActiveSection] = useState('brief') + // 规则冲突检测 + const [isCheckingConflicts, setIsCheckingConflicts] = useState(false) + const [showConflictModal, setShowConflictModal] = useState(false) + const [conflicts, setConflicts] = useState([]) + const [showPlatformSelect, setShowPlatformSelect] = useState(false) + + const platformDropdownRef = useRef(null) + + const platformOptions = [ + { value: 'douyin', label: '抖音' }, + { value: 'xiaohongshu', label: '小红书' }, + { value: 'bilibili', label: 'B站' }, + ] + + // 点击外部关闭平台选择下拉 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) { + setShowPlatformSelect(false) + } + } + if (showPlatformSelect) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showPlatformSelect]) + + const handleCheckConflicts = async (platform: string) => { + setShowPlatformSelect(false) + setIsCheckingConflicts(true) + + if (USE_MOCK) { + await new Promise(resolve => setTimeout(resolve, 1000)) + setConflicts([ + { + brief_rule: '卖点包含:100%纯天然成分', + platform_rule: `${platform} 禁止使用:100%`, + suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述", + }, + { + brief_rule: 'Brief 最长时长:5秒', + platform_rule: `${platform} 最短要求:7秒`, + suggestion: 'Brief 最长 5s 低于平台最短要求 7s,视频可能不达标', + }, + ]) + setShowConflictModal(true) + setIsCheckingConflicts(false) + return + } + + try { + const brandId = user?.brand_id || '' + const briefRules: Record = { + selling_points: sellingPoints.map(sp => sp.content), + min_duration: minDuration, + max_duration: maxDuration, + } + const result = await api.validateRules({ + brand_id: brandId, + platform, + brief_rules: briefRules, + }) + setConflicts(result.conflicts) + if (result.conflicts.length > 0) { + setShowConflictModal(true) + } else { + toast.success('未发现规则冲突') + } + } catch (err) { + console.error('规则冲突检测失败:', err) + toast.error('规则冲突检测失败') + } finally { + setIsCheckingConflicts(false) + } + } + // Input fields const [newSellingPoint, setNewSellingPoint] = useState('') const [newBlacklistWord, setNewBlacklistWord] = useState('') @@ -336,19 +416,54 @@ export default function ProjectConfigPage() {

- +
+
+ + {showPlatformSelect && ( +
+ {platformOptions.map((opt) => ( + + ))} +
+ )} +
+ +
{/* Brief配置 */} @@ -757,6 +872,54 @@ export default function ProjectConfigPage() { )} + + {/* 规则冲突检测结果弹窗 */} + setShowConflictModal(false)} + title="规则冲突检测结果" + size="lg" + > +
+ {conflicts.length === 0 ? ( +
+ +

未发现冲突

+

Brief 内容与平台规则兼容

+
+ ) : ( + <> +
+ +

+ 发现 {conflicts.length} 处规则冲突,建议在发布前修改 +

+
+ {conflicts.map((conflict, index) => ( +
+
+ Brief + {conflict.brief_rule} +
+
+ 平台 + {conflict.platform_rule} +
+
+ 建议 + {conflict.suggestion} +
+
+ ))} + + )} +
+ +
+
+
) }