feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知

后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-11 19:11:54 +08:00
co-authored by Claude Opus 4.6
parent 0c59797d5b
commit 0ef7650c09
43 changed files with 3909 additions and 1316 deletions
+235 -4
View File
@@ -1,8 +1,12 @@
"""
Brief API
项目 Brief 文档的 CRUD
项目 Brief 文档的 CRUD + AI 解析
"""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -21,6 +25,8 @@ from app.schemas.brief import (
)
from app.services.auth import generate_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
@@ -75,6 +81,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
file_url=brief.file_url,
file_name=brief.file_name,
selling_points=brief.selling_points,
min_selling_points=brief.min_selling_points,
blacklist_words=brief.blacklist_words,
competitors=brief.competitors,
brand_tone=brief.brand_tone,
@@ -192,9 +199,10 @@ async def update_brief_agency_attachments(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新 Brief 代理商附件(代理商操作)
"""更新 Brief 代理商配置(代理商操作)
代理商只能更新 agency_attachments 字段,不能修改品牌方设置的其他 Brief 内容
代理商更新agency_attachments、selling_points、blacklist_words
不能修改品牌方设置的核心 Brief 内容(文件、时长、竞品等)。
"""
# 权限检查:代理商必须属于该项目
result = await db.execute(
@@ -234,7 +242,7 @@ async def update_brief_agency_attachments(
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)
@@ -243,3 +251,226 @@ async def update_brief_agency_attachments(
await db.refresh(brief)
return _brief_to_response(brief)
# ==================== AI 解析 ====================
class BriefParseResponse(BaseModel):
"""Brief AI 解析响应"""
product_name: str = ""
target_audience: str = ""
content_requirements: str = ""
selling_points: list[dict] = []
blacklist_words: list[dict] = []
@router.post("/parse", response_model=BriefParseResponse)
async def parse_brief_with_ai(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
AI 解析 Brief 文档
从品牌方上传的 Brief 文件中提取结构化信息:
- 产品名称
- 目标人群
- 内容要求
- 卖点建议
- 违禁词建议
"""
# 权限检查(代理商需要属于该项目)
project = await _get_project_with_permission(project_id, current_user, db)
# 获取 Brief
result = await db.execute(
select(Brief)
.options(selectinload(Brief.project))
.where(Brief.project_id == project_id)
)
brief = result.scalar_one_or_none()
if not brief:
raise HTTPException(status_code=404, detail="Brief 不存在,请先让品牌方创建 Brief")
# 收集所有可解析的文档 URL
documents: list[dict] = [] # [{"url": ..., "name": ...}]
if brief.file_url and brief.file_name:
documents.append({"url": brief.file_url, "name": brief.file_name})
if brief.attachments:
for att in brief.attachments:
if att.get("url") and att.get("name"):
documents.append({"url": att["url"], "name": att["name"]})
if not documents:
raise HTTPException(status_code=400, detail="Brief 没有可解析的文件")
# 提取文本(每个文档限时 60 秒)
import asyncio
from app.services.document_parser import DocumentParser
all_texts = []
for doc in documents:
try:
text = await asyncio.wait_for(
DocumentParser.download_and_parse(doc["url"], doc["name"]),
timeout=60.0,
)
if text and text.strip():
all_texts.append(f"=== {doc['name']} ===\n{text}")
logger.info(f"成功解析文档 {doc['name']},提取 {len(text)} 字符")
except asyncio.TimeoutError:
logger.warning(f"解析文档 {doc['name']} 超时(60s),已跳过")
except Exception as e:
logger.warning(f"解析文档 {doc['name']} 失败: {e}")
if not all_texts:
raise HTTPException(status_code=400, detail="所有文档均解析失败,无法提取文本内容")
combined_text = "\n\n".join(all_texts)
# 截断过长文本
max_chars = 15000
if len(combined_text) > max_chars:
combined_text = combined_text[:max_chars] + "\n...(内容已截断)"
# 获取 AI 客户端
from app.services.ai_service import AIServiceFactory
tenant_id = project.brand_id or "default"
ai_client = await AIServiceFactory.get_client(tenant_id, db)
if not ai_client:
raise HTTPException(
status_code=400,
detail="AI 服务未配置,请在品牌方设置中配置 AI 服务",
)
config = await AIServiceFactory.get_config(tenant_id, db)
text_model = "gpt-4o"
if config and config.models:
text_model = config.models.get("text", "gpt-4o")
# AI 解析
prompt = f"""你是营销内容合规审核专家。请从以下品牌方 Brief 文档中提取结构化信息。
文档内容:
{combined_text}
请以 JSON 格式返回,不要包含其他内容:
{{
"product_name": "产品名称",
"target_audience": "目标人群描述",
"content_requirements": "内容创作要求的简要总结",
"selling_points": [
{{"content": "卖点1", "priority": "core"}},
{{"content": "卖点2", "priority": "recommended"}},
{{"content": "卖点3", "priority": "reference"}}
],
"blacklist_words": [
{{"word": "违禁词1", "reason": "原因"}},
{{"word": "违禁词2", "reason": "原因"}}
]
}}
说明:
- product_name: 从文档中识别的产品/品牌名称
- target_audience: 目标消费人群
- content_requirements: 对达人创作内容的要求(时长、风格、场景等)
- selling_points: 产品卖点,priority 说明:
- "core": 核心卖点,品牌方重点关注,建议优先传达
- "recommended": 推荐卖点,建议提及
- "reference": 参考信息,不要求出现在脚本中
- blacklist_words: 从文档中识别的需要避免的词语(绝对化用语、竞品名、敏感词等)"""
last_error = None
for attempt in range(2):
try:
response = await ai_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
model=text_model,
temperature=0.2 if attempt == 0 else 0.1,
max_tokens=2000,
)
# 提取 JSON
logger.info(f"AI 原始响应 (attempt={attempt}): {response.content[:500]}")
content = _extract_json_from_response(response.content)
logger.info(f"提取的 JSON: {content[:500]}")
parsed = json.loads(content)
return BriefParseResponse(
product_name=parsed.get("product_name", ""),
target_audience=parsed.get("target_audience", ""),
content_requirements=parsed.get("content_requirements", ""),
selling_points=parsed.get("selling_points", []),
blacklist_words=parsed.get("blacklist_words", []),
)
except json.JSONDecodeError as e:
last_error = e
logger.warning(f"AI 返回内容非 JSON (attempt={attempt}): {e}, raw={response.content[:300]}")
continue
except Exception as e:
logger.error(f"AI 解析 Brief 失败: {e}")
raise HTTPException(status_code=500, detail=f"AI 解析失败: {str(e)[:200]}")
# 两次都失败
logger.error(f"AI 解析 Brief JSON 格式错误,两次重试均失败: {last_error}")
raise HTTPException(status_code=500, detail="AI 解析结果格式错误,请重试")
def _extract_json_from_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()
# 尝试找到第一个 { 和最后一个 }
first_brace = text.find("{")
last_brace = text.rfind("}")
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
text = text[first_brace:last_brace + 1]
# 清理中文引号等特殊字符
text = _sanitize_json_string(text)
return text
def _sanitize_json_string(text: str) -> str:
"""
清理 AI 返回的 JSON 文本中的中文引号等特殊字符。
中文引号 "" 在 JSON 字符串值内会破坏解析。
"""
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)
+2
View File
@@ -141,6 +141,7 @@ async def create_project(
sender_name=brand.name,
)
await db.commit()
return await _project_to_response(project, db)
@@ -354,6 +355,7 @@ async def assign_agencies(
sender_name=brand.name,
)
await db.commit()
return await _project_to_response(project, db)
+63 -3
View File
@@ -131,10 +131,14 @@ _platform_rules = {
"xiaohongshu": {
"platform": "xiaohongshu",
"rules": [
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
{"type": "forbidden_word", "words": [
"最好", "绝对", "100%", "第一", "最佳", "国家级", "顶级",
"万能", "神器", "秒杀", "碾压", "永久", "根治",
"一次见效", "立竿见影", "无副作用",
]},
],
"version": "2024.01",
"updated_at": "2024-01-10T00:00:00Z",
"version": "2024.06",
"updated_at": "2024-06-15T00:00:00Z",
},
"bilibili": {
"platform": "bilibili",
@@ -336,6 +340,33 @@ async def add_to_whitelist(
)
@router.delete("/whitelist/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_whitelist_item(
item_id: str,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
):
"""删除白名单项"""
result = await db.execute(
select(WhitelistItem).where(
and_(
WhitelistItem.id == item_id,
WhitelistItem.tenant_id == x_tenant_id,
)
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"白名单项不存在: {item_id}",
)
await db.delete(item)
await db.flush()
# ==================== 竞品库 ====================
@router.get("/competitors", response_model=CompetitorListResponse)
@@ -1012,6 +1043,35 @@ async def get_forbidden_words_for_tenant(
]
async def get_competitors_for_brand(
tenant_id: str,
brand_id: str,
db: AsyncSession,
) -> list[dict]:
"""
获取品牌方配置的竞品列表
Returns:
[{"name": "竞品名", "keywords": ["关键词1", ...]}]
"""
result = await db.execute(
select(Competitor).where(
and_(
Competitor.tenant_id == tenant_id,
Competitor.brand_id == brand_id,
)
)
)
competitors = result.scalars().all()
return [
{
"name": c.name,
"keywords": c.keywords or [],
}
for c in competitors
]
async def get_active_platform_rules(
tenant_id: str,
brand_id: str,
+749 -146
View File
File diff suppressed because it is too large Load Diff
+557 -19
View File
@@ -2,13 +2,15 @@
任务 API
实现完整的审核任务流程
"""
import asyncio
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.database import get_db, AsyncSessionLocal
from app.models.user import User, UserRole
from app.models.task import Task, TaskStage, TaskStatus
from app.models.project import Project
@@ -41,6 +43,7 @@ from app.services.task_service import (
check_task_permission,
upload_script,
upload_video,
complete_ai_review,
agency_review,
brand_review,
submit_appeal,
@@ -53,10 +56,350 @@ from app.services.task_service import (
)
from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision
from app.services.message_service import create_message
from app.models.brief import Brief
from app.schemas.review import ScriptReviewRequest, Platform
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["任务"])
async def _run_script_ai_review(task_id: str, tenant_id: str):
"""
后台执行脚本 AI 审核
- 获取 Brief 信息(卖点、黑名单词)
- 调用 review_script 进行审核
- 保存审核结果并推进任务阶段
- 发送 SSE 通知
"""
from app.api.scripts import review_script
async with AsyncSessionLocal() as db:
try:
task = await get_task_by_id(db, task_id)
if not task or task.stage.value != "script_ai_review":
logger.warning(f"任务 {task_id} 不在 AI 审核阶段,跳过")
return
# 获取项目信息
project_result = await db.execute(
select(Project).where(Project.id == task.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
logger.error(f"任务 {task_id} 对应的项目不存在")
return
# 获取 Brief
brief_result = await db.execute(
select(Brief).where(Brief.project_id == project.id)
)
brief = brief_result.scalar_one_or_none()
# 构建审核请求
platform = project.platform or "douyin"
selling_points = brief.selling_points if brief else None
blacklist_words = brief.blacklist_words if brief else None
min_selling_points = brief.min_selling_points if brief else None
request = ScriptReviewRequest(
content=" ", # 占位,实际内容从 file_url 解析
platform=Platform(platform),
brand_id=project.brand_id,
selling_points=selling_points,
min_selling_points=min_selling_points,
blacklist_words=blacklist_words,
file_url=task.script_file_url,
file_name=task.script_file_name,
)
# 调用审核逻辑
result = await review_script(
request=request,
x_tenant_id=tenant_id,
db=db,
)
# 保存审核结果
task = await get_task_by_id(db, task_id)
task = await complete_ai_review(
db=db,
task=task,
review_type="script",
score=result.score,
result=result.model_dump(),
)
await db.commit()
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score}")
# SSE 通知达人和代理商
try:
user_ids = []
creator_result = await db.execute(
select(Creator).where(Creator.id == task.creator_id)
)
creator_obj = creator_result.scalar_one_or_none()
if creator_obj:
user_ids.append(creator_obj.user_id)
agency_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = agency_result.scalar_one_or_none()
if agency_obj:
user_ids.append(agency_obj.user_id)
if user_ids:
await notify_task_updated(
task_id=task.id,
user_ids=user_ids,
data={"action": "ai_review_completed", "stage": task.stage.value, "score": result.score},
)
except Exception:
pass
# 创建消息通知代理商
try:
ag_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
ag_obj = ag_result.scalar_one_or_none()
if ag_obj:
await create_message(
db=db,
user_id=ag_obj.user_id,
type="task",
title="脚本 AI 审核完成",
content=f"任务「{task.name}」AI 审核完成,综合得分 {result.score} 分,请审核。",
related_task_id=task.id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
# AI 未配置时通知品牌方
if not result.ai_available:
try:
brand_result = await db.execute(
select(Brand).where(Brand.id == project.brand_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj and brand_obj.user_id:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="AI 审核降级运行",
content=f"任务「{task.name}」的 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
related_task_id=task.id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
except Exception as e:
logger.error(f"任务 {task_id} AI 审核失败: {e}", exc_info=True)
await db.rollback()
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
try:
brand_result = await db.execute(
select(Brand).where(Brand.id == tenant_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj and brand_obj.user_id:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="AI 审核异常",
content=f"任务 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
related_task_id=task_id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
async def _run_video_ai_review(task_id: str, tenant_id: str):
"""
后台执行视频 AI 审核
复用脚本审核的完整规则检测链(违禁词/竞品/平台规则/白名单/AI深度分析)。
审核内容来源:已通过审核的脚本文本 + 视频文件(如可解析)。
"""
from app.api.scripts import review_script
async with AsyncSessionLocal() as db:
try:
await asyncio.sleep(2) # 模拟处理延迟
task = await get_task_by_id(db, task_id)
if not task or task.stage.value != "video_ai_review":
logger.warning(f"任务 {task_id} 不在视频 AI 审核阶段,跳过")
return
# 获取项目信息
project_result = await db.execute(
select(Project).where(Project.id == task.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
logger.error(f"任务 {task_id} 对应的项目不存在")
return
# 获取 Brief
brief_result = await db.execute(
select(Brief).where(Brief.project_id == project.id)
)
brief = brief_result.scalar_one_or_none()
platform = project.platform or "douyin"
selling_points = brief.selling_points if brief else None
blacklist_words = brief.blacklist_words if brief else None
min_selling_points = brief.min_selling_points if brief else None
# 使用脚本内容作为审核基础(视频 ASR 尚未实现,先复用脚本文本)
script_content = ""
if task.script_file_url and task.script_file_name:
# 脚本文件可用,复用
pass # review_script 会自动解析 file_url
request = ScriptReviewRequest(
content=script_content or " ",
platform=Platform(platform),
brand_id=project.brand_id,
selling_points=selling_points,
min_selling_points=min_selling_points,
blacklist_words=blacklist_words,
file_url=task.script_file_url,
file_name=task.script_file_name,
)
# 调用完整审核逻辑(竞品/违禁词/平台规则/白名单/AI深度分析全部参与)
result = await review_script(
request=request,
x_tenant_id=tenant_id,
db=db,
)
video_score = result.score
video_result = {
"score": video_score,
"summary": result.summary,
"violations": [v.model_dump() for v in result.violations],
"soft_warnings": [w.model_dump() for w in result.soft_warnings],
"dimensions": result.dimensions.model_dump(),
"selling_point_matches": [sp.model_dump() for sp in result.selling_point_matches],
}
task = await get_task_by_id(db, task_id)
task = await complete_ai_review(
db=db,
task=task,
review_type="video",
score=video_score,
result=video_result,
)
await db.commit()
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score}")
# SSE 通知
try:
user_ids = []
creator_result = await db.execute(
select(Creator).where(Creator.id == task.creator_id)
)
creator_obj = creator_result.scalar_one_or_none()
if creator_obj:
user_ids.append(creator_obj.user_id)
agency_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = agency_result.scalar_one_or_none()
if agency_obj:
user_ids.append(agency_obj.user_id)
if user_ids:
await notify_task_updated(
task_id=task.id,
user_ids=user_ids,
data={"action": "ai_review_completed", "stage": task.stage.value, "score": video_score},
)
except Exception:
pass
# 创建消息通知代理商
try:
ag_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
ag_obj = ag_result.scalar_one_or_none()
if ag_obj:
await create_message(
db=db,
user_id=ag_obj.user_id,
type="task",
title="视频 AI 审核完成",
content=f"任务「{task.name}」视频 AI 审核完成,得分 {video_score} 分,请审核。",
related_task_id=task.id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
# AI 未配置时通知品牌方
if not result.ai_available:
try:
brand_result = await db.execute(
select(Brand).where(Brand.id == project.brand_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj and brand_obj.user_id:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="视频 AI 审核降级运行",
content=f"任务「{task.name}」的视频 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
related_task_id=task.id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
except Exception as e:
logger.error(f"任务 {task_id} 视频 AI 审核失败: {e}", exc_info=True)
await db.rollback()
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
try:
brand_result = await db.execute(
select(Brand).where(Brand.id == tenant_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj and brand_obj.user_id:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="视频 AI 审核异常",
content=f"任务视频 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
related_task_id=task_id,
sender_name="系统",
)
await db.commit()
except Exception:
pass
def _task_to_response(task: Task) -> TaskResponse:
"""将数据库模型转换为响应模型"""
return TaskResponse(
@@ -175,30 +518,64 @@ async def create_new_task(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# 提取通知所需的值(commit 后 ORM 对象会过期,提前缓存)
_task_id = task.id
_task_name = task.name
_project_id = task.project.id
_project_name = task.project.name
_project_brand_id = task.project.brand_id
_agency_name = agency.name
_creator_user_id = creator.user_id
_creator_name = creator.name or creator.id
# 创建消息 + SSE 通知达人有新任务
try:
await create_message(
db=db,
user_id=creator.user_id,
user_id=_creator_user_id,
type="new_task",
title="新任务分配",
content=f"您有新的任务「{task.name}」,来自项目「{task.project.name}",
related_task_id=task.id,
related_project_id=task.project.id,
sender_name=agency.name,
content=f"您有新的任务「{_task_name}」,来自项目「{_project_name}",
related_task_id=_task_id,
related_project_id=_project_id,
sender_name=_agency_name,
)
await db.commit()
except Exception:
pass
except Exception as e:
logger.warning(f"创建达人通知消息失败: {e}")
# 通知品牌方:代理商给项目添加了达人
try:
brand_result = await db.execute(
select(Brand).where(Brand.id == _project_brand_id)
)
brand = brand_result.scalar_one_or_none()
if brand and brand.user_id:
await create_message(
db=db,
user_id=brand.user_id,
type="new_task",
title="达人加入项目",
content=f"代理商「{_agency_name}」将达人「{_creator_name}」加入项目「{_project_name}」,任务:{_task_name}",
related_task_id=_task_id,
related_project_id=_project_id,
sender_name=_agency_name,
)
await db.commit()
else:
logger.warning(f"品牌方不存在或无 user_id: brand_id={_project_brand_id}")
except Exception as e:
logger.warning(f"创建品牌方通知消息失败: {e}")
try:
await notify_new_task(
task_id=task.id,
creator_user_id=creator.user_id,
task_name=task.name,
project_name=task.project.name,
task_id=_task_id,
creator_user_id=_creator_user_id,
task_name=_task_name,
project_name=_project_name,
)
except Exception:
pass
except Exception as e:
logger.warning(f"SSE 通知失败: {e}")
return _task_to_response(task)
@@ -211,6 +588,7 @@ async def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
stage: Optional[TaskStage] = Query(None),
project_id: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -243,7 +621,7 @@ async def list_tasks(
status_code=status.HTTP_404_NOT_FOUND,
detail="代理商信息不存在",
)
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage)
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage, project_id)
elif current_user.role == UserRole.BRAND:
result = await db.execute(
@@ -255,7 +633,7 @@ async def list_tasks(
status_code=status.HTTP_404_NOT_FOUND,
detail="品牌方信息不存在",
)
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage)
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage, project_id)
else:
raise HTTPException(
@@ -395,13 +773,23 @@ async def upload_task_script(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商脚本已上传
# 通知代理商脚本已上传(消息 + SSE
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await create_message(
db=db,
user_id=agency_obj.user_id,
type="task",
title="达人已上传脚本",
content=f"任务「{task.name}」的脚本已上传,等待 AI 审核。",
related_task_id=task.id,
sender_name=creator.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
@@ -410,6 +798,18 @@ async def upload_task_script(
except Exception:
pass
# 获取 tenant_id (品牌方 ID) 并在后台触发 AI 审核
try:
project_result = await db.execute(
select(Project).where(Project.id == task.project_id)
)
project = project_result.scalar_one_or_none()
if project:
asyncio.create_task(_run_script_ai_review(task.id, project.brand_id))
logger.info(f"已触发任务 {task.id} 的后台 AI 审核")
except Exception as e:
logger.error(f"触发 AI 审核失败: {e}")
return _task_to_response(task)
@@ -458,13 +858,23 @@ async def upload_task_video(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商视频已上传
# 通知代理商视频已上传(消息 + SSE
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await create_message(
db=db,
user_id=agency_obj.user_id,
type="task",
title="达人已上传视频",
content=f"任务「{task.name}」的视频已上传,等待 AI 审核。",
related_task_id=task.id,
sender_name=creator.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
@@ -473,6 +883,18 @@ async def upload_task_video(
except Exception:
pass
# 获取 tenant_id 并在后台触发视频 AI 审核
try:
project_result = await db.execute(
select(Project).where(Project.id == task.project_id)
)
project = project_result.scalar_one_or_none()
if project:
asyncio.create_task(_run_video_ai_review(task.id, project.brand_id))
logger.info(f"已触发任务 {task.id} 的后台视频 AI 审核")
except Exception as e:
logger.error(f"触发视频 AI 审核失败: {e}")
return _task_to_response(task)
@@ -616,6 +1038,59 @@ async def review_script(
except Exception:
pass
# 代理商通过 → 通知品牌方有新内容待审核
try:
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
brand_result = await db.execute(
select(Brand).where(Brand.id == task.project.brand_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="新脚本待审核",
content=f"任务「{task.name}」脚本已通过代理商审核,请进行品牌终审。",
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[brand_obj.user_id],
data={"action": "script_pending_brand_review", "stage": task.stage.value},
)
except Exception:
pass
# 品牌方审核 → 通知代理商结果
try:
if current_user.role == UserRole.BRAND:
ag_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
ag_obj = ag_result.scalar_one_or_none()
if ag_obj:
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
await create_message(
db=db,
user_id=ag_obj.user_id,
type="task",
title=f"脚本品牌终审{action_text}",
content=f"任务「{task.name}」脚本品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[ag_obj.user_id],
data={"action": f"script_brand_{request.action}", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@@ -756,6 +1231,59 @@ async def review_video(
except Exception:
pass
# 代理商通过 → 通知品牌方有视频待审核
try:
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
brand_result = await db.execute(
select(Brand).where(Brand.id == task.project.brand_id)
)
brand_obj = brand_result.scalar_one_or_none()
if brand_obj:
await create_message(
db=db,
user_id=brand_obj.user_id,
type="task",
title="新视频待审核",
content=f"任务「{task.name}」视频已通过代理商审核,请进行品牌终审。",
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[brand_obj.user_id],
data={"action": "video_pending_brand_review", "stage": task.stage.value},
)
except Exception:
pass
# 品牌方审核 → 通知代理商结果
try:
if current_user.role == UserRole.BRAND:
ag_result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
ag_obj = ag_result.scalar_one_or_none()
if ag_obj:
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
await create_message(
db=db,
user_id=ag_obj.user_id,
type="task",
title=f"视频品牌终审{action_text}",
content=f"任务「{task.name}」视频品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[ag_obj.user_id],
data={"action": f"video_brand_{request.action}", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@@ -804,13 +1332,23 @@ async def submit_task_appeal(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商有新申诉
# 通知代理商有新申诉(消息 + SSE
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await create_message(
db=db,
user_id=agency_obj.user_id,
type="task",
title="达人提交申诉",
content=f"任务「{task.name}」的达人提交了申诉:{request.reason}",
related_task_id=task.id,
sender_name=creator.name,
)
await db.commit()
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
+116 -2
View File
@@ -1,6 +1,7 @@
"""
文件上传 API
"""
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
from pydantic import BaseModel
from typing import Optional
@@ -135,7 +136,6 @@ class SignedUrlResponse(BaseModel):
async def get_signed_url(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
download: bool = Query(False, description="是否强制下载(添加 Content-Disposition: attachment"),
current_user: User = Depends(get_current_user),
):
"""
@@ -158,7 +158,7 @@ async def get_signed_url(
)
try:
signed_url = generate_presigned_url(file_key, expire_seconds=expire, download=download)
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -171,6 +171,120 @@ async def get_signed_url(
)
def _get_tos_object(file_key: str) -> tuple[bytes, str]:
"""
TOS 获取文件内容和文件名内部工具函数
Returns:
(content, filename)
"""
import tos as tos_sdk
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)
content = resp.read()
# 从 file_key 提取文件名,去掉时间戳前缀
filename = file_key.split("/")[-1]
if "_" in filename and filename.split("_")[0].isdigit():
filename = filename.split("_", 1)[1]
return content, filename
def _resolve_file_key(url: str) -> str:
"""从 URL 或 file_key 解析出实际 file_key"""
from app.services.oss import parse_file_key_from_url
file_key = url
if url.startswith("http"):
file_key = parse_file_key_from_url(url)
return file_key
def _guess_content_type(filename: str) -> str:
"""根据文件名猜测 MIME 类型"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
mime_map = {
"pdf": "application/pdf",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"mov": "video/quicktime",
"webm": "video/webm",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"txt": "text/plain",
}
return mime_map.get(ext, "application/octet-stream")
@router.get("/download")
async def download_file(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
current_user: User = Depends(get_current_user),
):
"""
代理下载文件 后端获取 TOS 文件后返回给前端
设置 Content-Disposition: attachment 确保浏览器触发下载
"""
file_key = _resolve_file_key(url)
if not file_key:
raise HTTPException(status_code=400, detail="无效的文件路径")
try:
content, filename = _get_tos_object(file_key)
except Exception as e:
raise HTTPException(status_code=502, detail=f"下载文件失败: {e}")
from fastapi.responses import Response
encoded_filename = quote(filename)
return Response(
content=content,
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
},
)
@router.get("/preview")
async def preview_file(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
current_user: User = Depends(get_current_user),
):
"""
代理预览文件 后端获取 TOS 文件后返回给前端
设置正确的 Content-Type 让浏览器可以直接渲染PDF / 图片等
"""
file_key = _resolve_file_key(url)
if not file_key:
raise HTTPException(status_code=400, detail="无效的文件路径")
try:
content, filename = _get_tos_object(file_key)
except Exception as e:
raise HTTPException(status_code=502, detail=f"获取文件失败: {e}")
from fastapi.responses import Response
content_type = _guess_content_type(filename)
return Response(
content=content,
media_type=content_type,
)
@router.post("/proxy", response_model=FileUploadedResponse)
async def proxy_upload(
file: UploadFile = File(...),
+4 -1
View File
@@ -30,9 +30,12 @@ class Brief(Base, TimestampMixin):
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# 解析后的结构化内容
# 卖点要求: [{"content": "SPF50+", "required": true}, ...]
# 卖点要求: [{"content": "SPF50+", "priority": "core"}, ...]
selling_points: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 代理商要求至少体现的卖点条数(0 或 None 表示不限制)
min_selling_points: Mapped[Optional[int]] = mapped_column(nullable=True)
# 违禁词: [{"word": "最好", "reason": "绝对化用语"}, ...]
blacklist_words: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
+12 -1
View File
@@ -1,5 +1,10 @@
"""
Brief 相关 Schema
卖点格式 (selling_points: List[dict]):
新格式: {"content": "卖点内容", "priority": "core|recommended|reference"}
旧格式: {"content": "卖点内容", "required": true|false}
兼容规则: required=true priority="core", required=false priority="recommended"
"""
from typing import Optional, List
from datetime import datetime
@@ -39,8 +44,13 @@ class BriefUpdateRequest(BaseModel):
class AgencyBriefUpdateRequest(BaseModel):
"""代理商更新 Brief 请求(允许更新 agency_attachments"""
"""代理商更新 Brief 请求(允许更新代理商附件 + 卖点 + 违禁词 + AI解析内容"""
agency_attachments: Optional[List[dict]] = None
selling_points: Optional[List[dict]] = None
min_selling_points: Optional[int] = None
blacklist_words: Optional[List[dict]] = None
brand_tone: Optional[str] = None
other_requirements: Optional[str] = None
# ===== 响应 =====
@@ -53,6 +63,7 @@ class BriefResponse(BaseModel):
file_url: Optional[str] = None
file_name: Optional[str] = None
selling_points: Optional[List[dict]] = None
min_selling_points: Optional[int] = None
blacklist_words: Optional[List[dict]] = None
competitors: Optional[List[str]] = None
brand_tone: Optional[str] = None
+55 -6
View File
@@ -91,6 +91,7 @@ class Violation(BaseModel):
content: str = Field(..., description="违规内容")
severity: RiskLevel = Field(..., description="严重程度")
suggestion: str = Field(..., description="修改建议")
dimension: Optional[str] = Field(None, description="所属维度: legal/platform/brand_safety/brief_match")
# 文本审核字段
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
@@ -101,6 +102,45 @@ class Violation(BaseModel):
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
# ==================== 多维度审核 ====================
class ReviewDimension(BaseModel):
"""审核维度评分"""
score: int = Field(..., ge=0, le=100)
passed: bool
issue_count: int = 0
class ReviewDimensions(BaseModel):
"""四维度审核结果"""
legal: ReviewDimension # 法规合规(违禁词、功效词、Brief黑名单词)
platform: ReviewDimension # 平台规则
brand_safety: ReviewDimension # 品牌安全(竞品、其他品牌词)
brief_match: ReviewDimension # Brief 匹配度(卖点覆盖)
class SellingPointMatch(BaseModel):
"""卖点匹配结果"""
content: str
priority: str # "core" | "recommended" | "reference"
matched: bool
evidence: Optional[str] = None # AI 给出的匹配依据
class BriefMatchDetail(BaseModel):
"""Brief 匹配度评分详情"""
# 卖点覆盖
total_points: int = Field(0, description="需要检查的卖点总数(core + recommended")
matched_points: int = Field(0, description="实际匹配的卖点数")
required_points: int = Field(0, description="代理商要求至少体现的卖点条数(min_selling_points")
coverage_score: int = Field(0, ge=0, le=100, description="卖点覆盖率得分")
# AI 整体匹配分析
overall_score: int = Field(0, ge=0, le=100, description="整体 Brief 匹配度得分")
highlights: list[str] = Field(default_factory=list, description="内容亮点(AI 分析)")
issues: list[str] = Field(default_factory=list, description="问题点(AI 分析)")
explanation: str = Field("", description="评分说明(一句话总结)")
# ==================== 脚本预审 ====================
class ScriptReviewRequest(BaseModel):
@@ -108,9 +148,12 @@ class ScriptReviewRequest(BaseModel):
content: str = Field(..., min_length=1, description="脚本内容")
platform: Platform = Field(..., description="投放平台")
brand_id: str = Field(..., description="品牌 ID")
required_points: Optional[list[str]] = Field(None, description="必要卖点列表")
selling_points: Optional[list[dict]] = Field(None, description="卖点列表 [{content, priority}]")
min_selling_points: Optional[int] = Field(None, ge=0, description="代理商要求至少体现的卖点条数")
blacklist_words: Optional[list[dict]] = Field(None, description="Brief 黑名单词 [{word, reason}]")
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
file_url: Optional[str] = Field(None, description="脚本文件 URL(用于自动解析文本和提取图片)")
file_name: Optional[str] = Field(None, description="原始文件名(用于判断格式)")
class ScriptReviewResponse(BaseModel):
@@ -118,16 +161,22 @@ class ScriptReviewResponse(BaseModel):
脚本预审响应
结构
- score: 合规分数 0-100
- score: 加权总分向后兼容
- summary: 整体摘要
- violations: 违规项列表每项包含 suggestion
- missing_points: 遗漏的卖点可选
- dimensions: 四维度评分法规/平台/品牌安全/Brief匹配
- selling_point_matches: 卖点匹配详情
- violations: 违规项列表每项带 dimension 标签
- missing_points: 遗漏的核心卖点向后兼容
"""
score: int = Field(..., ge=0, le=100, description="合规分数")
score: int = Field(..., ge=0, le=100, description="加权总分")
summary: str = Field(..., description="审核摘要")
dimensions: ReviewDimensions = Field(..., description="四维度评分")
selling_point_matches: list[SellingPointMatch] = Field(default_factory=list, description="卖点匹配详情")
brief_match_detail: Optional[BriefMatchDetail] = Field(None, description="Brief 匹配度评分详情")
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
missing_points: Optional[list[str]] = Field(None, description="遗漏的核心卖点")
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
ai_available: bool = Field(True, description="AI 服务是否可用(False 表示降级为纯关键词检测)")
# ==================== 视频审核 ====================
+3
View File
@@ -51,6 +51,9 @@ class OpenAICompatibleClient:
timeout: float = 180.0,
):
self.base_url = base_url.rstrip("/")
# 自动补全 /v1 后缀(OpenAI SDK 需要完整路径)
if not self.base_url.endswith("/v1"):
self.base_url = self.base_url + "/v1"
self.api_key = api_key
self.provider = provider
self.timeout = timeout
+18 -12
View File
@@ -53,18 +53,24 @@ class AIServiceFactory:
)
config = result.scalar_one_or_none()
if not config:
return None
# 解密 API Key
api_key = decrypt_api_key(config.api_key_encrypted)
# 创建客户端
client = OpenAICompatibleClient(
base_url=config.base_url,
api_key=api_key,
provider=config.provider,
)
if config:
# 解密 API Key
api_key = decrypt_api_key(config.api_key_encrypted)
client = OpenAICompatibleClient(
base_url=config.base_url,
api_key=api_key,
provider=config.provider,
)
else:
# 回退到全局 .env 配置
from app.config import settings
if not settings.AI_API_KEY or not settings.AI_API_BASE_URL:
return None
client = OpenAICompatibleClient(
base_url=settings.AI_API_BASE_URL,
api_key=settings.AI_API_KEY,
provider=settings.AI_PROVIDER,
)
# 缓存客户端
cls._cache[cache_key] = client
+128 -30
View File
@@ -2,12 +2,16 @@
文档解析服务
PDF/Word/Excel 文档中提取纯文本
"""
import asyncio
import logging
import os
import tempfile
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
class DocumentParser:
"""从文档中提取纯文本"""
@@ -38,23 +42,40 @@ class DocumentParser:
# 回退:生成预签名 URL 后用 HTTP 下载
content = await DocumentParser._download_via_signed_url(document_url)
# 跳过过大的文件(>20MB),解析可能非常慢且阻塞
if len(content) > 20 * 1024 * 1024:
logger.warning(f"文件 {document_name} 过大 ({len(content)//1024//1024}MB),已跳过")
return ""
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
tmp.write(content)
tmp_path = tmp.name
return DocumentParser.parse_file(tmp_path, document_name)
# 文件解析可能很慢(CPU 密集),放到线程池执行
return await asyncio.to_thread(DocumentParser.parse_file, tmp_path, document_name)
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# 图片提取限制
MAX_IMAGES = 10
MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2MB per image base64
@staticmethod
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
"""
下载 PDF 并将页面转为 base64 图片列表用于图片型 PDF AI 视觉解析
PDF 或非图片型 PDF 返回 None
下载文档并提取嵌入的图片返回 base64 编码列表
支持格式
- PDF: 图片型 PDF 转页面图片
- DOCX: 提取 word/media/ 中的嵌入图片
- XLSX: 提取 worksheet 中的嵌入图片
Returns:
base64 图片列表无图片时返回 None
"""
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
if ext != "pdf":
if ext not in ("pdf", "doc", "docx", "xls", "xlsx"):
return None
tmp_path: Optional[str] = None
@@ -63,12 +84,20 @@ class DocumentParser:
if file_content is None:
file_content = await DocumentParser._download_via_signed_url(document_url)
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") 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)
if ext == "pdf":
if DocumentParser.is_image_pdf(tmp_path):
return DocumentParser.pdf_to_images_base64(tmp_path)
return None
elif ext in ("doc", "docx"):
images = await asyncio.to_thread(DocumentParser._extract_docx_images, tmp_path)
return images if images else None
elif ext in ("xls", "xlsx"):
images = await asyncio.to_thread(DocumentParser._extract_xlsx_images, tmp_path)
return images if images else None
return None
finally:
if tmp_path and os.path.exists(tmp_path):
@@ -76,32 +105,40 @@ class DocumentParser:
@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
"""通过 TOS SDK 直接下载文件(私有桶安全访问),在线程池中执行避免阻塞"""
def _sync_download() -> Optional[bytes]:
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:
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
logger.debug("TOS SDK: AK/SK 未配置,跳过")
return None
file_key = parse_file_key_from_url(document_url)
if not file_key or file_key == document_url:
logger.debug(f"TOS SDK: 无法从 URL 解析 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)
data = resp.read()
logger.info(f"TOS SDK: 下载成功, key={file_key}, size={len(data)}")
return data
except Exception as e:
logger.warning(f"TOS SDK 下载失败,将回退 HTTP: {e}")
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
return await asyncio.to_thread(_sync_download)
@staticmethod
async def _download_via_signed_url(document_url: str) -> bytes:
@@ -110,10 +147,12 @@ class DocumentParser:
file_key = parse_file_key_from_url(document_url)
signed_url = generate_presigned_url(file_key, expire_seconds=300)
logger.info(f"HTTP 签名 URL 下载: key={file_key}")
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(signed_url)
resp.raise_for_status()
logger.info(f"HTTP 下载成功: {len(resp.content)} bytes")
return resp.content
@staticmethod
@@ -249,3 +288,62 @@ class DocumentParser:
"""纯文本文件"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
@staticmethod
def _extract_docx_images(path: str) -> list[str]:
"""从 DOCX 文件中提取嵌入图片(DOCX 本质是 ZIP,图片在 word/media/ 目录)"""
import zipfile
import base64
images = []
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
try:
with zipfile.ZipFile(path, "r") as zf:
for name in zf.namelist():
if not name.startswith("word/media/"):
continue
ext = os.path.splitext(name)[1].lower()
if ext not in image_exts:
continue
img_data = zf.read(name)
b64 = base64.b64encode(img_data).decode()
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
logger.debug(f"跳过过大图片: {name} ({len(b64)} bytes)")
continue
images.append(b64)
if len(images) >= DocumentParser.MAX_IMAGES:
break
except Exception as e:
logger.warning(f"提取 DOCX 图片失败: {e}")
return images
@staticmethod
def _extract_xlsx_images(path: str) -> list[str]:
"""从 XLSX 文件中提取嵌入图片(通过 openpyxl 的 _images 属性)"""
import base64
images = []
try:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=False)
for sheet in wb.worksheets:
for img in getattr(sheet, "_images", []):
try:
img_data = img._data()
b64 = base64.b64encode(img_data).decode()
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
continue
images.append(b64)
if len(images) >= DocumentParser.MAX_IMAGES:
break
except Exception:
continue
if len(images) >= DocumentParser.MAX_IMAGES:
break
wb.close()
except Exception as e:
logger.warning(f"提取 XLSX 图片失败: {e}")
return images
+8 -17
View File
@@ -142,8 +142,6 @@ def get_file_url(file_key: str) -> str:
def generate_presigned_url(
file_key: str,
expire_seconds: int = 3600,
download: bool = False,
filename: str | None = None,
) -> str:
"""
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
@@ -179,21 +177,14 @@ def generate_presigned_url(
# 对 file_key 中的路径段分别编码
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
# 查询参数(按字母序排列TOS V4 签名要求严格字母序
params_dict: dict[str, str] = {
"X-Tos-Algorithm": "TOS4-HMAC-SHA256",
"X-Tos-Credential": quote(credential, safe=''),
"X-Tos-Date": tos_date,
"X-Tos-Expires": str(expire_seconds),
"X-Tos-SignedHeaders": "host",
}
if download:
dl_name = filename or file_key.split("/")[-1]
params_dict["response-content-disposition"] = quote(
f'attachment; filename="{dl_name}"', safe=''
)
query_params = "&".join(f"{k}={v}" for k, v in sorted(params_dict.items()))
# 查询参数(按字母序排列)
query_params = (
f"X-Tos-Algorithm=TOS4-HMAC-SHA256"
f"&X-Tos-Credential={quote(credential, safe='')}"
f"&X-Tos-Date={tos_date}"
f"&X-Tos-Expires={expire_seconds}"
f"&X-Tos-SignedHeaders=host"
)
# CanonicalRequest
canonical_request = (
+14 -4
View File
@@ -459,6 +459,7 @@ async def list_tasks_for_agency(
page: int = 1,
page_size: int = 20,
stage: Optional[TaskStage] = None,
project_id: Optional[str] = None,
) -> Tuple[List[Task], int]:
"""获取代理商的任务列表"""
query = (
@@ -473,6 +474,8 @@ async def list_tasks_for_agency(
if stage:
query = query.where(Task.stage == stage)
if project_id:
query = query.where(Task.project_id == project_id)
query = query.order_by(Task.created_at.desc())
@@ -480,6 +483,8 @@ async def list_tasks_for_agency(
count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id)
if stage:
count_query = count_query.where(Task.stage == stage)
if project_id:
count_query = count_query.where(Task.project_id == project_id)
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
@@ -497,12 +502,17 @@ async def list_tasks_for_brand(
page: int = 1,
page_size: int = 20,
stage: Optional[TaskStage] = None,
project_id: Optional[str] = None,
) -> Tuple[List[Task], int]:
"""获取品牌方的任务列表(通过项目关联)"""
# 先获取品牌方的所有项目
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
project_ids_result = await db.execute(project_ids_query)
project_ids = [row[0] for row in project_ids_result.all()]
if project_id:
# 指定了项目 ID,直接筛选该项目的任务
project_ids = [project_id]
else:
# 未指定项目,获取品牌方的所有项目
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
project_ids_result = await db.execute(project_ids_query)
project_ids = [row[0] for row in project_ids_result.all()]
if not project_ids:
return [], 0