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