feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知

- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 19:00:03 +08:00
co-authored by Claude Opus 4.6
parent 58aed5f201
commit 4c9b2f1263
40 changed files with 1479 additions and 360 deletions
+63
View File
@@ -16,6 +16,7 @@ from app.api.deps import get_current_user
from app.schemas.brief import (
BriefCreateRequest,
BriefUpdateRequest,
AgencyBriefUpdateRequest,
BriefResponse,
)
from app.services.auth import generate_id
@@ -81,6 +82,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
max_duration=brief.max_duration,
other_requirements=brief.other_requirements,
attachments=brief.attachments,
agency_attachments=brief.agency_attachments,
created_at=brief.created_at,
updated_at=brief.updated_at,
)
@@ -137,6 +139,7 @@ async def create_brief(
max_duration=request.max_duration,
other_requirements=request.other_requirements,
attachments=request.attachments,
agency_attachments=request.agency_attachments,
)
db.add(brief)
await db.flush()
@@ -180,3 +183,63 @@ async def update_brief(
await db.refresh(brief)
return _brief_to_response(brief)
@router.patch("/agency-attachments", response_model=BriefResponse)
async def update_brief_agency_attachments(
project_id: str,
request: AgencyBriefUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新 Brief 代理商附件(代理商操作)
代理商只能更新 agency_attachments 字段,不能修改品牌方设置的其他 Brief 内容。
"""
# 权限检查:代理商必须属于该项目
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
if current_user.role == UserRole.AGENCY:
agency_result = await db.execute(
select(Agency).where(Agency.user_id == current_user.id)
)
agency = agency_result.scalar_one_or_none()
if not agency or agency not in project.agencies:
raise HTTPException(status_code=403, detail="无权访问此项目")
elif current_user.role == UserRole.BRAND:
# 品牌方也可以更新代理商附件
brand_result = await db.execute(
select(Brand).where(Brand.user_id == current_user.id)
)
brand = brand_result.scalar_one_or_none()
if not brand or project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权访问此项目")
else:
raise HTTPException(status_code=403, detail="无权修改代理商附件")
# 获取 Brief
brief_result = await db.execute(
select(Brief)
.options(selectinload(Brief.project))
.where(Brief.project_id == project_id)
)
brief = brief_result.scalar_one_or_none()
if not brief:
raise HTTPException(status_code=404, detail="Brief 不存在")
# 仅更新 agency_attachments
update_fields = request.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(brief, field, value)
await db.flush()
await db.refresh(brief)
return _brief_to_response(brief)
+27 -2
View File
@@ -23,6 +23,7 @@ from app.schemas.project import (
AgencySummary,
)
from app.services.auth import generate_id
from app.services.message_service import create_message
router = APIRouter(prefix="/projects", tags=["项目"])
@@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes
id=project.id,
name=project.name,
description=project.description,
platform=project.platform,
brand_id=project.brand_id,
brand_name=project.brand.name if project.brand else None,
status=project.status,
@@ -72,6 +74,7 @@ async def create_project(
brand_id=brand.id,
name=request.name,
description=request.description,
platform=request.platform,
start_date=request.start_date,
deadline=request.deadline,
status="active",
@@ -79,7 +82,7 @@ async def create_project(
db.add(project)
await db.flush()
# 分配代理商
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
if request.agency_ids:
for agency_id in request.agency_ids:
result = await db.execute(
@@ -87,7 +90,12 @@ async def create_project(
)
agency = result.scalar_one_or_none()
if agency:
project.agencies.append(agency)
await db.execute(
project_agency_association.insert().values(
project_id=project.id,
agency_id=agency.id,
)
)
await db.flush()
await db.refresh(project)
@@ -100,6 +108,21 @@ async def create_project(
)
project = result.scalar_one()
# 给品牌方用户发送项目创建成功消息
brand_user_result = await db.execute(
select(User).where(User.id == brand.user_id)
)
brand_user = brand_user_result.scalar_one_or_none()
if brand_user:
await create_message(
db=db,
user_id=brand_user.id,
type="system_notice",
title="项目创建成功",
content=f"您的项目「{project.name}」已创建成功",
related_project_id=project.id,
)
return await _project_to_response(project, db)
@@ -248,6 +271,8 @@ async def update_project(
project.name = request.name
if request.description is not None:
project.description = request.description
if request.platform is not None:
project.platform = request.platform
if request.start_date is not None:
project.start_date = request.start_date
if request.deadline is not None:
+162 -20
View File
@@ -558,22 +558,40 @@ async def parse_platform_rule_document(
"""
await _ensure_tenant_exists(x_tenant_id, db)
# 1. 下载并解析文档
# 1. 尝试提取文本;对图片型 PDF 走视觉解析
document_text = ""
image_b64_list: list[str] = []
try:
document_text = await DocumentParser.download_and_parse(
# 先检查是否为图片型 PDF
image_b64_list = await DocumentParser.download_and_get_images(
request.document_url, request.document_name,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
) or []
except Exception as e:
logger.error(f"文档解析失败: {e}")
raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}")
logger.warning(f"图片 PDF 检测失败,回退文本模式: {e}")
if not document_text.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
if not image_b64_list:
# 非图片 PDF 或检测失败,走文本提取
try:
document_text = await DocumentParser.download_and_parse(
request.document_url, request.document_name,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"文档解析失败: {e}")
raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}")
# 2. AI 解析
parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db)
if not document_text.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
# 2. AI 解析(图片模式 or 文本模式)
if image_b64_list:
parsed_rules = await _ai_parse_platform_rules_vision(
x_tenant_id, request.platform, image_b64_list, db,
)
else:
parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db)
# 3. 存入 DB (draft)
rule_id = f"pr-{uuid.uuid4().hex[:8]}"
@@ -757,7 +775,8 @@ async def _ai_parse_platform_rules(
- duration: 视频时长要求,如果文档未提及则为 null
- content_requirements: 内容上的硬性要求
- other_rules: 不属于以上分类的其他规则
- 如果某项没有提取到内容,使用空数组或 null"""
- 如果某项没有提取到内容,使用空数组或 null
- 重要:JSON 字符串值中不要使用中文引号(""),使用单引号或直接省略"""
response = await ai_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
@@ -767,12 +786,7 @@ async def _ai_parse_platform_rules(
)
# 解析 AI 响应
content = response.content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content.rsplit("\n", 1)[0]
content = _extract_json_from_ai_response(response.content)
parsed = json.loads(content)
# 校验并补全字段
@@ -784,14 +798,142 @@ async def _ai_parse_platform_rules(
"other_rules": parsed.get("other_rules", []),
}
except json.JSONDecodeError:
logger.warning("AI 返回内容非 JSON,降级为空规则")
except json.JSONDecodeError as e:
logger.warning(f"AI 返回内容非 JSON,降级为空规则: {e}")
return _empty_parsed_rules()
except Exception as e:
logger.error(f"AI 解析平台规则失败: {e}")
return _empty_parsed_rules()
async def _ai_parse_platform_rules_vision(
tenant_id: str,
platform: str,
image_b64_list: list[str],
db: AsyncSession,
) -> dict:
"""
使用 AI 视觉模型从 PDF 页面图片中提取结构化平台规则。
用于扫描件/截图型 PDF。
"""
try:
ai_client = await AIServiceFactory.get_client(tenant_id, db)
if not ai_client:
logger.warning(f"租户 {tenant_id} 未配置 AI 服务,返回空规则")
return _empty_parsed_rules()
config = await AIServiceFactory.get_config(tenant_id, db)
if not config:
return _empty_parsed_rules()
vision_model = config.models.get("vision", config.models.get("text", "gpt-4o"))
# 构建多模态消息
content: list[dict] = [
{
"type": "text",
"text": f"""你是平台广告合规规则分析专家。以下是 {platform} 平台规则文档的页面截图。
请仔细阅读所有页面,从中提取结构化规则。
请以 JSON 格式返回,不要包含其他内容:
{{
"forbidden_words": ["违禁词1", "违禁词2"],
"restricted_words": [{{"word": "xx", "condition": "使用条件", "suggestion": "替换建议"}}],
"duration": {{"min_seconds": 7, "max_seconds": null}},
"content_requirements": ["必须展示产品正面", "需要口播品牌名"],
"other_rules": [{{"rule": "规则名称", "description": "详细说明"}}]
}}
注意:
- forbidden_words: 明确禁止使用的词语
- restricted_words: 有条件限制的词语
- duration: 视频时长要求,如果文档未提及则为 null
- content_requirements: 内容上的硬性要求
- other_rules: 不属于以上分类的其他规则
- 如果某项没有提取到内容,使用空数组或 null
- 重要:JSON 字符串值中不要使用中文引号(\u201c\u201d),使用单引号或直接省略""",
}
]
for b64 in image_b64_list:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
response = await ai_client.chat_completion(
messages=[{"role": "user", "content": content}],
model=vision_model,
temperature=0.2,
max_tokens=3000,
)
# 解析 AI 响应
resp_content = _extract_json_from_ai_response(response.content)
parsed = json.loads(resp_content)
return {
"forbidden_words": parsed.get("forbidden_words", []),
"restricted_words": parsed.get("restricted_words", []),
"duration": parsed.get("duration"),
"content_requirements": parsed.get("content_requirements", []),
"other_rules": parsed.get("other_rules", []),
}
except json.JSONDecodeError as e:
logger.warning(f"AI 视觉解析返回内容非 JSON,降级为空规则: {e}")
return _empty_parsed_rules()
except Exception as e:
logger.error(f"AI 视觉解析平台规则失败: {e}")
return _empty_parsed_rules()
def _extract_json_from_ai_response(raw: str) -> str:
"""
从 AI 响应中提取并清理 JSON 文本。
处理:markdown 代码块包裹、中文引号等。
"""
import re
text = raw.strip()
# 去掉 markdown ```json ... ``` 包裹
m = re.search(r'```(?:json)?\s*\n(.*?)```', text, re.DOTALL)
if m:
text = m.group(1).strip()
return _sanitize_json_string(text)
def _sanitize_json_string(text: str) -> str:
"""
清理 AI 返回的 JSON 文本中的中文引号等特殊字符。
中文引号 "" 在 JSON 字符串值内会破坏解析。
"""
import re
result = []
in_string = False
i = 0
while i < len(text):
ch = text[i]
if ch == '\\' and in_string and i + 1 < len(text):
result.append(ch)
result.append(text[i + 1])
i += 2
continue
if ch == '"' and not in_string:
in_string = True
result.append(ch)
elif ch == '"' and in_string:
in_string = False
result.append(ch)
elif in_string and ch in '\u201c\u201d\u300c\u300d':
# 中文引号 "" 和「」 → 单引号
result.append("'")
elif not in_string and ch in '\u201c\u201d':
# JSON 结构层的中文引号 → 英文双引号
result.append('"')
else:
result.append(ch)
i += 1
return ''.join(result)
def _empty_parsed_rules() -> dict:
"""返回空的解析规则结构"""
return {
+59 -1
View File
@@ -1,7 +1,7 @@
"""
文件上传 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
@@ -168,3 +168,61 @@ async def get_signed_url(
signed_url=signed_url,
expire_seconds=expire,
)
@router.post("/proxy", response_model=FileUploadedResponse)
async def proxy_upload(
file: UploadFile = File(...),
file_type: str = Form("general"),
current_user: User = Depends(get_current_user),
):
"""
后端代理上传(用于本地开发 / 浏览器无法直连 TOS 的场景)
前端把文件 POST 到此接口,后端使用 TOS SDK 上传到对象存储。
"""
import io
import tos as tos_sdk
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise HTTPException(status_code=500, detail="TOS 配置未设置")
now = datetime.now()
base_dir = f"uploads/{now.year}/{now.month:02d}"
type_dirs = {"script": "scripts", "video": "videos", "image": "images"}
sub_dir = type_dirs.get(file_type, "files")
file_key = f"{base_dir}/{sub_dir}/{int(now.timestamp())}_{file.filename}"
content = await file.read()
content_type = file.content_type or "application/octet-stream"
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
try:
client = tos_sdk.TosClientV2(
ak=settings.TOS_ACCESS_KEY_ID,
sk=settings.TOS_SECRET_ACCESS_KEY,
endpoint=f"https://{endpoint}",
region=region,
)
client.put_object(
bucket=settings.TOS_BUCKET_NAME,
key=file_key,
content=io.BytesIO(content),
content_type=content_type,
)
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"TOS 上传失败: {str(e)[:200]}",
)
url = get_file_url(file_key)
return FileUploadedResponse(
url=url,
file_key=file_key,
file_name=file.filename or "unknown",
file_size=len(content),
file_type=file_type,
)