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
@@ -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')
+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,
)
+3 -2
View File
@@ -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")
+5 -1
View File
@@ -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")
+3
View File
@@ -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),
+2 -2
View File
@@ -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,
+5 -5
View File
@@ -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)
+1 -1
View File
@@ -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,
)
+8
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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
+146 -14
View File
@@ -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:
+6 -1
View File
@@ -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 前端
+1
View File
@@ -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",
+1 -1
View File
@@ -25,7 +25,7 @@ alembic upgrade head
# 填充种子数据
echo "填充种子数据..."
python -m scripts.seed
python3 -m scripts.seed
echo ""
echo "=== 基础服务已启动 ==="