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:
co-authored by
Claude Opus 4.6
parent
58aed5f201
commit
4c9b2f1263
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user