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
@@ -22,13 +22,15 @@ def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
platform_enum = postgresql.ENUM(
|
||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||
name='platform_enum'
|
||||
name='platform_enum',
|
||||
create_type=False,
|
||||
)
|
||||
platform_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
task_status_enum = postgresql.ENUM(
|
||||
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
||||
name='task_status_enum'
|
||||
name='task_status_enum',
|
||||
create_type=False,
|
||||
)
|
||||
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
|
||||
@@ -17,38 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=True,
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_content", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_file_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("manual_tasks", "script_uploaded_at")
|
||||
op.drop_column("manual_tasks", "script_file_url")
|
||||
op.drop_column("manual_tasks", "script_content")
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("manual_tasks", "video_uploaded_at")
|
||||
pass
|
||||
|
||||
@@ -22,7 +22,8 @@ def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
user_role_enum = postgresql.ENUM(
|
||||
'brand', 'agency', 'creator',
|
||||
name='user_role_enum'
|
||||
name='user_role_enum',
|
||||
create_type=False,
|
||||
)
|
||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
@@ -30,10 +31,15 @@ def upgrade() -> None:
|
||||
'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review',
|
||||
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
|
||||
'completed', 'rejected',
|
||||
name='task_stage_enum'
|
||||
name='task_stage_enum',
|
||||
create_type=False,
|
||||
)
|
||||
task_stage_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# 扩展 task_status_enum:添加 Task 模型需要的值
|
||||
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'passed'")
|
||||
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'force_passed'")
|
||||
|
||||
# 用户表
|
||||
op.create_table(
|
||||
'users',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""添加 Brief 代理商附件字段
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-02-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '007'
|
||||
down_revision: Union[str, None] = '006'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('briefs', sa.Column('agency_attachments', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('briefs', 'agency_attachments')
|
||||
@@ -0,0 +1,26 @@
|
||||
"""添加项目发布平台字段
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-02-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '008'
|
||||
down_revision: Union[str, None] = '007'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('projects', sa.Column('platform', sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('projects', 'platform')
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+3
-2
@@ -54,8 +54,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Rate limiting
|
||||
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
||||
# Rate limiting (仅生产环境启用)
|
||||
if _is_production:
|
||||
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
|
||||
@@ -49,10 +49,14 @@ class Brief(Base, TimestampMixin):
|
||||
# 其他要求(自由文本)
|
||||
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 附件文档(代理商上传的参考资料)
|
||||
# 附件文档(品牌方上传的参考资料)
|
||||
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
||||
attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 代理商附件(代理商上传的补充资料,与品牌方 attachments 分开存储)
|
||||
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
||||
agency_attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 关联
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="brief")
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class Project(Base, TimestampMixin):
|
||||
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 发布平台 (douyin/xiaohongshu/bilibili/kuaishou 等)
|
||||
platform: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||
|
||||
# 状态
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
|
||||
@@ -47,7 +47,7 @@ class ReviewTask(Base, TimestampMixin):
|
||||
# 视频信息
|
||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
SQLEnum(Platform, name="platform_enum"),
|
||||
SQLEnum(Platform, name="platform_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
@@ -55,7 +55,7 @@ class ReviewTask(Base, TimestampMixin):
|
||||
|
||||
# 审核状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
||||
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
|
||||
@@ -70,7 +70,7 @@ class Task(Base, TimestampMixin):
|
||||
|
||||
# 当前阶段
|
||||
stage: Mapped[TaskStage] = mapped_column(
|
||||
SQLEnum(TaskStage, name="task_stage_enum"),
|
||||
SQLEnum(TaskStage, name="task_stage_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStage.SCRIPT_UPLOAD,
|
||||
nullable=False,
|
||||
index=True,
|
||||
@@ -88,7 +88,7 @@ class Task(Base, TimestampMixin):
|
||||
|
||||
# 脚本代理商审核
|
||||
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
||||
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=True,
|
||||
)
|
||||
script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
@@ -97,7 +97,7 @@ class Task(Base, TimestampMixin):
|
||||
|
||||
# 脚本品牌方终审
|
||||
script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=True,
|
||||
)
|
||||
script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
@@ -118,7 +118,7 @@ class Task(Base, TimestampMixin):
|
||||
|
||||
# 视频代理商审核
|
||||
video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=True,
|
||||
)
|
||||
video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
@@ -127,7 +127,7 @@ class Task(Base, TimestampMixin):
|
||||
|
||||
# 视频品牌方终审
|
||||
video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=True,
|
||||
)
|
||||
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -37,7 +37,7 @@ class User(Base, TimestampMixin):
|
||||
|
||||
# 角色
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SQLEnum(UserRole, name="user_role_enum"),
|
||||
SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ class BriefCreateRequest(BaseModel):
|
||||
max_duration: Optional[int] = None
|
||||
other_requirements: Optional[str] = None
|
||||
attachments: Optional[List[dict]] = None
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class BriefUpdateRequest(BaseModel):
|
||||
@@ -34,6 +35,12 @@ class BriefUpdateRequest(BaseModel):
|
||||
max_duration: Optional[int] = None
|
||||
other_requirements: Optional[str] = None
|
||||
attachments: Optional[List[dict]] = None
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class AgencyBriefUpdateRequest(BaseModel):
|
||||
"""代理商更新 Brief 请求(仅允许更新 agency_attachments)"""
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
@@ -53,6 +60,7 @@ class BriefResponse(BaseModel):
|
||||
max_duration: Optional[int] = None
|
||||
other_requirements: Optional[str] = None
|
||||
attachments: Optional[List[dict]] = None
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class ProjectCreateRequest(BaseModel):
|
||||
"""创建项目请求(品牌方操作)"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
deadline: Optional[datetime] = None
|
||||
agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表
|
||||
@@ -21,6 +22,7 @@ class ProjectUpdateRequest(BaseModel):
|
||||
"""更新项目请求"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
deadline: Optional[datetime] = None
|
||||
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
|
||||
@@ -45,6 +47,7 @@ class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
brand_id: str
|
||||
brand_name: Optional[str] = None
|
||||
status: str
|
||||
|
||||
@@ -48,7 +48,7 @@ class OpenAICompatibleClient:
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider: str = "openai",
|
||||
timeout: float = 60.0,
|
||||
timeout: float = 180.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
|
||||
@@ -17,6 +17,9 @@ class DocumentParser:
|
||||
"""
|
||||
下载文档并解析为纯文本
|
||||
|
||||
优先使用 TOS SDK 直接下载(私有桶无需签名),
|
||||
回退到 HTTP 预签名 URL 下载。
|
||||
|
||||
Args:
|
||||
document_url: 文档 URL (TOS)
|
||||
document_name: 原始文件名(用于判断格式)
|
||||
@@ -24,16 +27,19 @@ class DocumentParser:
|
||||
Returns:
|
||||
提取的纯文本
|
||||
"""
|
||||
# 下载到临时文件
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(document_url)
|
||||
resp.raise_for_status()
|
||||
|
||||
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||
|
||||
# 优先用 TOS SDK 直接下载(后端有 AK/SK,无需签名 URL)
|
||||
content = await DocumentParser._download_via_tos_sdk(document_url)
|
||||
|
||||
if content is None:
|
||||
# 回退:生成预签名 URL 后用 HTTP 下载
|
||||
content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
return DocumentParser.parse_file(tmp_path, document_name)
|
||||
@@ -41,6 +47,75 @@ class DocumentParser:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@staticmethod
|
||||
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
|
||||
"""
|
||||
下载 PDF 并将页面转为 base64 图片列表(用于图片型 PDF 的 AI 视觉解析)。
|
||||
非 PDF 或非图片型 PDF 返回 None。
|
||||
"""
|
||||
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||
if ext != "pdf":
|
||||
return None
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
file_content = await DocumentParser._download_via_tos_sdk(document_url)
|
||||
if file_content is None:
|
||||
file_content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(file_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
if DocumentParser.is_image_pdf(tmp_path):
|
||||
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||
return None
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_tos_sdk(document_url: str) -> Optional[bytes]:
|
||||
"""通过 TOS SDK 直接下载文件(私有桶安全访问)"""
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
import tos as tos_sdk
|
||||
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
return None
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
if not file_key or file_key == document_url:
|
||||
return None
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
return resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_signed_url(document_url: str) -> bytes:
|
||||
"""生成预签名 URL 后通过 HTTP 下载"""
|
||||
from app.services.oss import generate_presigned_url, parse_file_key_from_url
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=300)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(signed_url)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
@staticmethod
|
||||
def parse_file(file_path: str, file_name: str) -> str:
|
||||
"""
|
||||
@@ -68,16 +143,73 @@ class DocumentParser:
|
||||
|
||||
@staticmethod
|
||||
def _parse_pdf(path: str) -> str:
|
||||
"""pdfplumber 提取 PDF 文本"""
|
||||
import pdfplumber
|
||||
"""PyMuPDF 提取 PDF 文本,回退 pdfplumber"""
|
||||
import fitz
|
||||
|
||||
texts = []
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
texts.append(text)
|
||||
return "\n".join(texts)
|
||||
doc = fitz.open(path)
|
||||
for page in doc:
|
||||
text = page.get_text()
|
||||
if text and text.strip():
|
||||
texts.append(text.strip())
|
||||
doc.close()
|
||||
|
||||
result = "\n".join(texts)
|
||||
|
||||
# 如果 PyMuPDF 提取文本太少,回退 pdfplumber
|
||||
if len(result.strip()) < 100:
|
||||
try:
|
||||
import pdfplumber
|
||||
texts2 = []
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
texts2.append(text)
|
||||
fallback = "\n".join(texts2)
|
||||
if len(fallback.strip()) > len(result.strip()):
|
||||
result = fallback
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def pdf_to_images_base64(path: str, max_pages: int = 5, dpi: int = 150) -> list[str]:
|
||||
"""
|
||||
将 PDF 页面渲染为图片并返回 base64 编码列表。
|
||||
用于处理扫描件/图片型 PDF。
|
||||
"""
|
||||
import fitz
|
||||
import base64
|
||||
|
||||
images = []
|
||||
doc = fitz.open(path)
|
||||
for i, page in enumerate(doc):
|
||||
if i >= max_pages:
|
||||
break
|
||||
zoom = dpi / 72
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
img_bytes = pix.tobytes("png")
|
||||
b64 = base64.b64encode(img_bytes).decode()
|
||||
images.append(b64)
|
||||
doc.close()
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def is_image_pdf(path: str) -> bool:
|
||||
"""判断 PDF 是否为扫描件/图片型(文本内容极少)"""
|
||||
import fitz
|
||||
|
||||
doc = fitz.open(path)
|
||||
total_text = ""
|
||||
for page in doc:
|
||||
total_text += page.get_text()
|
||||
doc.close()
|
||||
# 去掉页码等噪音后,有效文字少于 200 字符视为图片 PDF
|
||||
cleaned = "".join(c for c in total_text if c.strip())
|
||||
return len(cleaned) < 200
|
||||
|
||||
@staticmethod
|
||||
def _parse_docx(path: str) -> str:
|
||||
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -21,6 +23,8 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: miaosi-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
healthcheck:
|
||||
@@ -82,7 +86,8 @@ services:
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
celery-worker: {}
|
||||
celery-worker:
|
||||
condition: service_started
|
||||
command: celery -A app.celery_app beat -l info
|
||||
|
||||
# Next.js 前端
|
||||
|
||||
@@ -165,6 +165,7 @@ async def seed_data() -> None:
|
||||
brand_id=BRAND_ID,
|
||||
name="2026春季新品推广",
|
||||
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
|
||||
platform="douyin",
|
||||
start_date=NOW,
|
||||
deadline=NOW + timedelta(days=30),
|
||||
status="active",
|
||||
|
||||
@@ -25,7 +25,7 @@ alembic upgrade head
|
||||
|
||||
# 填充种子数据
|
||||
echo "填充种子数据..."
|
||||
python -m scripts.seed
|
||||
python3 -m scripts.seed
|
||||
|
||||
echo ""
|
||||
echo "=== 基础服务已启动 ==="
|
||||
|
||||
Reference in New Issue
Block a user