Compare commits
61
Commits
dd06502004
..
main
+53
@@ -0,0 +1,53 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database data
|
||||
backend/data/
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,94 @@
|
||||
stages:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.cache/npm"
|
||||
|
||||
# ─── Backend Jobs ────────────────────────────────────────────────
|
||||
|
||||
backend-lint:
|
||||
stage: lint
|
||||
image: python:3.12-slim
|
||||
only:
|
||||
- main
|
||||
- merge_requests
|
||||
cache:
|
||||
key: backend-pip
|
||||
paths:
|
||||
- .cache/pip
|
||||
script:
|
||||
- cd backend
|
||||
- python3 -c "
|
||||
import ast, sys, pathlib;
|
||||
ok = True;
|
||||
for p in sorted(pathlib.Path('.').rglob('*.py')):
|
||||
try:
|
||||
ast.parse(p.read_text());
|
||||
except SyntaxError as e:
|
||||
print(f'SyntaxError in {p}\u003a {e}');
|
||||
ok = False;
|
||||
if not ok:
|
||||
sys.exit(1);
|
||||
print(f'All {len(list(pathlib.Path(\".\").rglob(\"*.py\")))} Python files passed syntax check')
|
||||
"
|
||||
|
||||
backend-test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
only:
|
||||
- main
|
||||
- merge_requests
|
||||
cache:
|
||||
key: backend-pip
|
||||
paths:
|
||||
- .cache/pip
|
||||
variables:
|
||||
DATABASE_URL: "sqlite+aiosqlite:///./test.db"
|
||||
SECRET_KEY: "ci-test-secret-key"
|
||||
script:
|
||||
- cd backend
|
||||
- pip install -e ".[dev]" aiosqlite "bcrypt<5" --quiet
|
||||
- pytest tests/ -x -q --tb=short
|
||||
|
||||
# ─── Frontend Jobs ───────────────────────────────────────────────
|
||||
|
||||
.frontend-base:
|
||||
image: node:20-slim
|
||||
only:
|
||||
- main
|
||||
- merge_requests
|
||||
cache:
|
||||
key: frontend-npm
|
||||
paths:
|
||||
- .cache/npm
|
||||
- frontend/node_modules
|
||||
before_script:
|
||||
- cd frontend
|
||||
- npm ci --prefer-offline
|
||||
|
||||
frontend-lint:
|
||||
extends: .frontend-base
|
||||
stage: lint
|
||||
script:
|
||||
- npm run lint
|
||||
|
||||
frontend-typecheck:
|
||||
extends: .frontend-base
|
||||
stage: lint
|
||||
script:
|
||||
- npx tsc --noEmit
|
||||
|
||||
frontend-test:
|
||||
extends: .frontend-base
|
||||
stage: test
|
||||
script:
|
||||
- npm test -- --run
|
||||
|
||||
frontend-build:
|
||||
extends: .frontend-base
|
||||
stage: build
|
||||
script:
|
||||
- npm run build
|
||||
+7
-5
@@ -2,9 +2,9 @@
|
||||
|
||||
| 文档类型 | **Technical Design (技术设计文档)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V2.0 |
|
||||
| **日期** | 2026-02-02 |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V2.1 |
|
||||
| **日期** | 2026-02-03 |
|
||||
| **侧重** | AI 服务动态配置、多租户隔离、模型选择 |
|
||||
|
||||
---
|
||||
@@ -15,6 +15,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| V1.0 | 2026-02-02 | Claude | 初稿:AI 厂商动态配置架构设计 |
|
||||
| V2.0 | 2026-02-02 | Claude | 重构:简化为统一提供商+三模型配置方案 |
|
||||
| V2.1 | 2026-02-03 | Claude | 文档一致性修订:明确单提供商模式与可切换原则 |
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
### 1.1 业务需求
|
||||
|
||||
SmartAudit 系统需要调用三类 AI 服务完成视频审核:
|
||||
秒思智能审核平台 系统需要调用三类 AI 服务完成视频审核:
|
||||
|
||||
| 服务类型 | 用途 | 示例模型 |
|
||||
| --- | --- | --- |
|
||||
@@ -35,6 +36,7 @@ SmartAudit 系统需要调用三类 AI 服务完成视频审核:
|
||||
| 目标 | 描述 |
|
||||
| --- | --- |
|
||||
| **灵活配置** | 品牌方可在后台自由选择 AI 提供商和模型 |
|
||||
| **单一提供商** | 每租户仅保留一套提供商配置,必要时手动切换 |
|
||||
| **统一接入** | 支持 OneAPI/OpenRouter 中转,一套配置调用多种模型 |
|
||||
| **直连支持** | 也支持直连 Anthropic、OpenAI、DeepSeek 等厂商 |
|
||||
| **多租户隔离** | 不同品牌方使用独立的 AI 配置和配额 |
|
||||
@@ -693,7 +695,7 @@ def mask_api_key(api_key: str) -> str:
|
||||
|
||||
## 7. 界面设计
|
||||
|
||||
> 详见 UIDesign.md 第 10 章「AI 服务配置界面」
|
||||
> 详见 User_Role_Interfaces.md 第 4.6 章「AI 服务配置」
|
||||
|
||||
### 7.1 界面入口
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# CLAUDE.md — 秒思智能审核平台
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 前端 (frontend/)
|
||||
```bash
|
||||
cd frontend && npm run dev # 开发服务器 http://localhost:3000
|
||||
cd frontend && npm run build # 生产构建(含类型检查)
|
||||
cd frontend && npm run lint # ESLint 检查
|
||||
cd frontend && npm test # Vitest 测试
|
||||
cd frontend && npm run test:coverage # 覆盖率报告
|
||||
```
|
||||
|
||||
### 后端 (backend/)
|
||||
```bash
|
||||
cd backend && uvicorn app.main:app --reload # 开发服务器 http://localhost:8000
|
||||
cd backend && pytest # 运行测试
|
||||
cd backend && pytest --cov # 带覆盖率
|
||||
cd backend && pytest -m "not slow" # 跳过慢测试
|
||||
cd backend && alembic upgrade head # 执行数据库迁移
|
||||
cd backend && alembic revision --autogenerate -m "msg" # 生成迁移
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
cd backend && docker-compose up # 启动 PostgreSQL + Redis + API + Celery
|
||||
```
|
||||
|
||||
## 项目架构
|
||||
|
||||
**秒思智能审核平台** — AI 营销内容合规审核系统,支持品牌方/代理商/达人三端。
|
||||
|
||||
```
|
||||
video-compliance-ai/
|
||||
├── frontend/ Next.js 14 + TypeScript + TailwindCSS (App Router)
|
||||
├── backend/ FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL
|
||||
├── documents/ 产品文档 (PRD, 设计稿, 约定等)
|
||||
└── scripts/ 工具脚本
|
||||
```
|
||||
|
||||
### 前端结构
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ ├── login/, register/ 认证页面
|
||||
│ ├── creator/ 达人端(任务/上传/申诉)
|
||||
│ ├── agency/ 代理商端(审核/管理/报表)
|
||||
│ └── brand/ 品牌方端(项目/规则/AI配置)
|
||||
├── components/ui/ 通用 UI 组件
|
||||
├── lib/api.ts Axios API 客户端(所有后端接口已封装)
|
||||
├── lib/taskStageMapper.ts 任务阶段 → UI 状态映射
|
||||
├── hooks/ 自定义 Hooks(useOSSUpload 等)
|
||||
├── contexts/ AuthContext, SSEContext
|
||||
└── types/ TypeScript 类型定义(与后端 schema 对齐)
|
||||
```
|
||||
|
||||
### 后端结构
|
||||
```
|
||||
backend/app/
|
||||
├── main.py FastAPI 应用入口,API 前缀 /api/v1
|
||||
├── config.py Pydantic Settings 配置
|
||||
├── database.py SQLAlchemy async session
|
||||
├── celery_app.py Celery 配置
|
||||
├── api/ 路由(auth, tasks, projects, briefs, organizations, dashboard, sse, upload, scripts, videos, rules, ai_config)
|
||||
├── models/ SQLAlchemy ORM 模型
|
||||
├── schemas/ Pydantic 请求/响应 schema
|
||||
├── services/ 业务逻辑层
|
||||
├── tasks/ Celery 异步任务
|
||||
└── utils/ 工具函数
|
||||
```
|
||||
|
||||
## 关键约定
|
||||
|
||||
### 认证与多租户
|
||||
- JWT 双 Token:access 15min + refresh 7天
|
||||
- localStorage keys:`miaosi_access_token`, `miaosi_refresh_token`, `miaosi_user`
|
||||
- 品牌方 = 租户,数据按品牌方隔离
|
||||
- 组织关系多对多:品牌方 ↔ 代理商 ↔ 达人
|
||||
|
||||
### ID 规范
|
||||
- 语义化前缀 + 6位数字:`BR`(品牌方), `AG`(代理商), `CR`(达人), `PJ`(项目), `TK`(任务), `BF`(Brief)
|
||||
|
||||
### Mock 模式
|
||||
- `USE_MOCK` 标志从 `contexts/AuthContext.tsx` 导出
|
||||
- 开发环境或 `NEXT_PUBLIC_USE_MOCK=true` 时为 true
|
||||
- 每个页面在 `loadData()` 中先检查 `USE_MOCK`,为 true 则使用本地 mock 数据
|
||||
|
||||
### 前端数据加载模式
|
||||
```typescript
|
||||
const loadData = useCallback(async () => {
|
||||
if (USE_MOCK) { setData(mockData); setLoading(false); return }
|
||||
try {
|
||||
const res = await api.someMethod()
|
||||
setData(res)
|
||||
} catch (err) {
|
||||
toast.error('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
```
|
||||
|
||||
### AI 服务
|
||||
- 通过中转服务商(OneAPI/OneInAll)调用,不直连 AI 厂商
|
||||
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
||||
|
||||
### 文件上传
|
||||
- 火山引擎 TOS 直传,前端通过 `useOSSUpload` hook 处理
|
||||
- 流程:`api.getUploadPolicy()` → POST 到 TOS → `api.fileUploaded()` 回调
|
||||
- TOS V4 签名:HMAC-SHA256,字段包括 `x-tos-algorithm`、`x-tos-credential`、`x-tos-date`、`x-tos-signature`、`policy`
|
||||
|
||||
### 实时推送
|
||||
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
||||
- 前端通过 `SSEContext` 提供 `subscribe(eventType, handler)` API
|
||||
|
||||
## 设计系统
|
||||
|
||||
### 暗色主题配色
|
||||
- 背景:`bg-page`(#0B0B0E), `bg-card`(#16161A), `bg-elevated`(#1A1A1E)
|
||||
- 文字:`text-primary`(#FAFAF9), `text-secondary`(#6B6B70), `text-tertiary`(#4A4A50)
|
||||
- 强调色:`accent-indigo`(#6366F1), `accent-green`(#32D583), `accent-coral`(#E85A4F), `accent-amber`(#FFB547)
|
||||
- 边框:`border-subtle`(#2A2A2E), `border-strong`(#3A3A40)
|
||||
|
||||
### 字体
|
||||
- 正文:DM Sans
|
||||
- 展示:Fraunces
|
||||
|
||||
## 任务审核流程
|
||||
```
|
||||
脚本上传 → AI审核 → 代理商审核 → 品牌终审 → 视频上传 → AI审核 → 代理商审核 → 品牌终审 → 完成
|
||||
```
|
||||
对应 `TaskStage`:`script_upload` → `script_ai_review` → `script_agency_review` → `script_brand_review` → `video_upload` → ... → `completed`
|
||||
|
||||
## 注意事项
|
||||
- 后端 Celery 异步任务(视频审核处理)尚未完整实现
|
||||
- 数据库已有 3 个 Alembic 迁移版本
|
||||
- `.pen` 文件是加密设计文件,只能通过 Pencil MCP 工具访问
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# 项目约定与设计决策
|
||||
|
||||
本文档记录容易被误解的设计决策,确保开发一致性。
|
||||
|
||||
---
|
||||
|
||||
## AI 服务配置
|
||||
|
||||
**重要:系统使用 AI 中转服务商,不直连 AI 厂商!**
|
||||
|
||||
- 使用 OneAPI / OneInAll / OpenRouter 等中转服务商
|
||||
- 中转服务商统一了不同 AI 厂商(豆包、通义、DeepSeek 等)的接口
|
||||
- 只需配置中转服务商的 API Key 和 Base URL
|
||||
- 具体使用哪个底层模型,由中转服务商的模型名称决定
|
||||
|
||||
```python
|
||||
# 正确 ✓
|
||||
AI_PROVIDER = "oneapi"
|
||||
AI_API_BASE_URL = "https://api.oneinall.ai/v1"
|
||||
|
||||
# 错误 ✗ - 不直接配置 AI 厂商
|
||||
AI_PROVIDER = "doubao"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用户认证
|
||||
|
||||
### 登录方式
|
||||
|
||||
支持两种登录方式(二选一):
|
||||
- **邮箱 + 密码**
|
||||
- **手机号 + 验证码**
|
||||
|
||||
### 注册流程
|
||||
|
||||
- 自助注册,支持邮箱注册或手机号注册
|
||||
- 邮箱注册后,可在设置中绑定手机号,之后可用手机号登录
|
||||
- 手机号注册后,可在设置中绑定邮箱,之后可用邮箱登录
|
||||
- 两种登录方式关联同一账号
|
||||
|
||||
### JWT 双 Token 方案
|
||||
|
||||
**后端设计:**
|
||||
```json
|
||||
// POST /api/v1/auth/login 返回
|
||||
{
|
||||
"accessToken": "xxx", // 有效期 15 分钟
|
||||
"refreshToken": "xxx", // 有效期 7 天
|
||||
"user": {
|
||||
"id": "user-001",
|
||||
"name": "张三",
|
||||
"email": "zhang@example.com",
|
||||
"phone": "138****8888",
|
||||
"role": "agency",
|
||||
"tenantId": "tenant-001",
|
||||
"tenantName": "美妆品牌A"
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/refresh 刷新 Token
|
||||
// 请求:{ "refreshToken": "xxx" }
|
||||
// 返回:{ "accessToken": "新的 accessToken" }
|
||||
```
|
||||
|
||||
**前端设计(Axios 拦截器):**
|
||||
1. Token 存入 localStorage
|
||||
2. 请求拦截器:自动添加 `Authorization: Bearer {accessToken}`
|
||||
3. 响应拦截器:
|
||||
- 监听 401 错误
|
||||
- 触发后暂停后续请求
|
||||
- 调用 `/refresh` 接口换新 Token
|
||||
- 更新本地存储
|
||||
- 自动重发失败的原请求
|
||||
- 若刷新也失败,清空存储并跳转登录页
|
||||
|
||||
---
|
||||
|
||||
## 多租户与组织关系
|
||||
|
||||
### 租户定义
|
||||
|
||||
- **租户 = 品牌方**
|
||||
- 数据隔离按品牌方划分(品牌方 A 看不到品牌方 B 的数据)
|
||||
|
||||
### 组织关系(多对多)
|
||||
|
||||
```
|
||||
品牌方 ←→ 代理商(多对多)
|
||||
代理商 ←→ 达人(多对多)
|
||||
```
|
||||
|
||||
- 一个品牌方可以有多个代理商
|
||||
- 一个代理商可以服务多个品牌方
|
||||
- 一个代理商可以有多个达人
|
||||
- 一个达人可以服务多个代理商
|
||||
|
||||
### 注册与邀请流程
|
||||
|
||||
1. **品牌方**:自助注册
|
||||
2. **代理商**:自助注册 → 被品牌方邀请加入
|
||||
3. **达人**:自助注册 → 被代理商邀请加入
|
||||
|
||||
### 任务分配流程
|
||||
|
||||
```
|
||||
品牌方发布项目
|
||||
↓
|
||||
分配给多个代理商
|
||||
↓
|
||||
代理商选择达人并分配任务(可多次选同一达人)
|
||||
↓
|
||||
达人收到任务:宣传任务(1)、宣传任务(2)...
|
||||
↓
|
||||
达人只能接受/完成任务,不能自己创建任务
|
||||
```
|
||||
|
||||
### 语义化 ID
|
||||
|
||||
- 代理商 ID:`AG` + 6位数字,如 `AG123456`
|
||||
- 达人 ID:`CR` + 6位数字,如 `CR123456`
|
||||
- 品牌方 ID:`BR` + 6位数字,如 `BR123456`
|
||||
|
||||
---
|
||||
|
||||
## 文件存储
|
||||
|
||||
### 存储服务
|
||||
|
||||
- **阿里云 OSS**
|
||||
- 公开访问(无需签名 URL)
|
||||
|
||||
### 上传方式
|
||||
|
||||
- **前端直传 OSS**
|
||||
- 后端提供 STS 临时凭证或签名
|
||||
- 前端使用阿里云 OSS SDK 直传
|
||||
|
||||
### 文件大小限制
|
||||
|
||||
- 最大 **500MB**
|
||||
- 采用**分片上传(Multipart Upload)**
|
||||
- 适应达人上传高清原片的需求
|
||||
|
||||
### 支持的文件类型
|
||||
|
||||
| 类型 | 格式 |
|
||||
|------|------|
|
||||
| 脚本 | .docx, .pdf, .xlsx, .txt, .pptx |
|
||||
| 视频 | .mp4, .mov, .webm |
|
||||
| 图片 | .jpg, .png, .gif |
|
||||
|
||||
---
|
||||
|
||||
## AI 审核能力
|
||||
|
||||
### 统一走中转
|
||||
|
||||
所有 AI 能力都通过中转服务商调用,不单独接入其他服务:
|
||||
|
||||
| 能力 | 实现方式 |
|
||||
|------|----------|
|
||||
| 文本分析 | 中转 LLM |
|
||||
| 语音转文字 (ASR) | 中转(如 Whisper) |
|
||||
| 字幕识别 (OCR) | 中转视觉模型 |
|
||||
| Logo/画面检测 | 中转视觉模型 + Brief/规则 |
|
||||
|
||||
### 模型列表
|
||||
|
||||
- 从后端动态获取(后端可从中转商 API 拉取可用模型)
|
||||
- 前端不硬编码模型列表
|
||||
|
||||
### Logo 检测逻辑
|
||||
|
||||
- 将 Brief 中的竞品信息 + 平台规则传给 AI
|
||||
- AI 分析视频画面,识别是否出现竞品 Logo
|
||||
- 不需要自训练模型,依赖多模态 AI 的理解能力
|
||||
|
||||
---
|
||||
|
||||
## 审核流程
|
||||
|
||||
### 品牌方终审
|
||||
|
||||
- **默认开启**
|
||||
- 品牌方可在设置中关闭
|
||||
|
||||
### 强制通过权
|
||||
|
||||
- **代理商默认拥有**
|
||||
- 品牌方可按代理商关闭此权限
|
||||
|
||||
### 申诉机制
|
||||
|
||||
- 每个任务初始 **1 次** 申诉机会
|
||||
- 用完后可向代理商申请增加
|
||||
- 代理商可同意/拒绝申请
|
||||
|
||||
---
|
||||
|
||||
## Brief 与规则
|
||||
|
||||
### 平台规则库
|
||||
|
||||
- 抖音/小红书/B站等平台规则
|
||||
- **手动录入**,后台管理
|
||||
- 规则更新需人工维护
|
||||
|
||||
### Brief 解析
|
||||
|
||||
- AI 自动解析 Brief 文档
|
||||
- 提取:卖点、违禁词、品牌调性要求
|
||||
- 支持格式:PDF/Word/Excel/PPT/图片
|
||||
|
||||
---
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 项目 | 选择 |
|
||||
|------|------|
|
||||
| 数据库 | PostgreSQL |
|
||||
| 缓存 | Redis |
|
||||
| 消息推送 | **SSE**(Server-Sent Events) |
|
||||
| 邮件/短信 | 暂不实现 |
|
||||
| 文件存储 | 阿里云 OSS |
|
||||
| AI 服务 | 中转服务商(OneAPI 等) |
|
||||
|
||||
---
|
||||
|
||||
## 其他约定
|
||||
|
||||
(后续添加)
|
||||
+38
-18
@@ -1,4 +1,4 @@
|
||||
这是一个基于 `RequirementsDoc.md`、`FeatureSummary.md` (V1.3) 和 `User_Role_Interfaces.md` 编写的开发计划文档。
|
||||
这是一个基于 `RequirementsDoc.md`、`FeatureSummary.md` (V1.4) 和 `User_Role_Interfaces.md` 编写的开发计划文档。
|
||||
|
||||
这份文档旨在指导技术团队进行架构设计、选型和排期,重点在于解决**视频处理的高并发/高延迟**、**多模态 AI 的集成**以及**移动端适配**等工程难点。
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
|
||||
| 文档类型 | **Development Plan (技术架构与实施计划)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.5 |
|
||||
| **日期** | 2026-02-03 |
|
||||
| **依据** | FeatureSummary V1.3, PRD V1.0, RequirementsDoc V1.0 |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.7 |
|
||||
| **日期** | 2026-02-05 |
|
||||
| **依据** | FeatureSummary V1.7, PRD V1.0, User_Role_Interfaces V1.6 |
|
||||
| **侧重** | 技术选型、架构设计、MVP 范围、开发排期、验收标准 |
|
||||
|
||||
---
|
||||
@@ -29,6 +29,8 @@
|
||||
| V1.3 | 2026-02-03 | Claude | **确立 TDD 为项目核心开发规范**,关联 tdd_plan.md |
|
||||
| V1.4 | 2026-02-03 | Claude | **新增 AI 厂商动态配置架构**,支持数据库配置、运行时热更新、多租户隔离 |
|
||||
| V1.5 | 2026-02-03 | Claude | 文档一致性修复:统一加密方案、采样精度、处理时间、选型决策、P0 范围、排期等 |
|
||||
| V1.6 | 2026-02-03 | Claude | 文档一致性修订:AI 配置单提供商模式、审计日志不可篡改方案、FeatureSummary 版本对齐 |
|
||||
| V1.7 | 2026-02-05 | Claude | 更新依据文档版本(FeatureSummary V1.7),与两阶段审核流程对齐 |
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +110,7 @@ graph TD
|
||||
| --- | --- | --- |
|
||||
| **通用语义 (NLP)** | **豆包 Pro / Qwen-Max / DeepSeek** | 处理 Brief 解析、反讽识别、情感分析 |
|
||||
| **视觉理解 (VLM)** | **Qwen-VL / 豆包视觉** | 处理复杂场景理解(如:环境脏乱差、具体动作判定);**Brief 图片解析** |
|
||||
| **语音识别 (ASR)** | **Paraformer (阿里) / SenseVoice** | 高精度中文语音转写,支持时间戳对齐 |
|
||||
| **语音识别 (ASR)** | **Whisper / Paraformer / SenseVoice** | 通过 AIProviderConfig 配置音频模型,支持时间戳对齐 |
|
||||
| **文字识别 (OCR)** | **PaddleOCR v4** | 针对中文视频字幕优化,开源免费,轻量级 |
|
||||
| **版面分析 (Layout)** | **PaddleOCR Layout / LayoutLMv3** | Brief PDF 版面分析,提取图文混排结构 |
|
||||
| **竞品 Logo 检测** | **Grounding DINO + Vector DB** | ⭐ V1.2 修正:改为向量检索方案,见下方说明 |
|
||||
@@ -119,11 +121,11 @@ graph TD
|
||||
>
|
||||
> **核心特性:**
|
||||
> - **数据库存储配置:** AI 厂商的 API Key、Base URL 等配置存储在数据库中,而非环境变量
|
||||
> - **运行时动态加载:** 管理员可在后台配置 AI 厂商,系统运行时动态读取配置初始化客户端
|
||||
> - **多租户隔离:** 不同品牌方可配置独立的 AI 厂商和配额
|
||||
> - **运行时动态加载:** 管理员可在后台配置**单一 AI 提供商**,系统运行时动态读取配置初始化客户端
|
||||
> - **多租户隔离:** 不同品牌方可配置独立的 AI 提供商配置和配额
|
||||
> - **热更新:** 配置变更即时生效,无需重启服务
|
||||
> - **故障转移:** 主厂商不可用时自动切换到备用厂商
|
||||
> - **API Key 加密:** 使用 AES-256-GCM 加密存储敏感信息
|
||||
> - **可切换:** 管理员可随时更换提供商(OneAPI/OpenRouter 或直连厂商)
|
||||
>
|
||||
> **支持的厂商类型:**
|
||||
> - 国内厂商:DeepSeek、通义千问、豆包、智谱、Moonshot
|
||||
@@ -252,11 +254,11 @@ sequenceDiagram
|
||||
|
||||
## 3. MVP (P0) 开发范围定义
|
||||
|
||||
基于 `FeatureSummary.md V1.3`,MVP 阶段必须包含的功能:
|
||||
基于 `FeatureSummary.md V1.4`,MVP 阶段必须包含的功能:
|
||||
|
||||
### ✅ MVP 包含 (Must Have) - 共 21 个 P0 功能
|
||||
|
||||
基于 `FeatureSummary.md V1.3` 第 4.1 章定义:
|
||||
基于 `FeatureSummary.md V1.4` 第 4.1 章定义:
|
||||
|
||||
| 模块 | 功能编号 | 功能名称 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -399,20 +401,22 @@ sequenceDiagram
|
||||
| --- | --- | --- |
|
||||
| `brands` | 品牌方 | id, name, settings_json |
|
||||
| `agencies` | 代理商 | id, brand_id, name |
|
||||
| `creators` | 达人 | id, agency_id, credit_score, appeal_tokens |
|
||||
| `tasks` | 审核任务 | id, brand_id, agency_id, creator_id, status, platform |
|
||||
| `creators` | 达人 | id, agency_id, credit_score |
|
||||
| `tasks` | 审核任务 | id, brand_id, agency_id, creator_id, status, platform, appeal_remaining, appeal_used |
|
||||
| `briefs` | Brief 规则 | id, task_id, raw_file_url, parsed_rules_json |
|
||||
| `videos` | 视频文件 | id, task_id, version, file_url, duration |
|
||||
| `reports` | 审核报告 | id, video_id, ai_result_json, human_decision, created_at |
|
||||
| `risk_items` | 风险项 | id, report_id, type, level, timestamp_start, timestamp_end, evidence_json |
|
||||
| `rule_sets` | 规则库 | id, brand_id, platform, version, rules_json |
|
||||
| `audit_logs` | 审计日志 | id, task_id, operator_id, action, detail_json, created_at |
|
||||
| `audit_logs` | 审计日志 | id, task_id, operator_id, action, detail_json, created_at, prev_hash, hash |
|
||||
|
||||
> **审计日志不可篡改策略:** `audit_logs` 采用 append-only 写入 + hash chain(前序哈希 + 当前内容),禁止更新/删除,支持链式校验。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准 (Acceptance Criteria)
|
||||
|
||||
引用自 `FeatureSummary.md V1.3` 第 9 章,MVP 上线前必须满足:
|
||||
引用自 `FeatureSummary.md V1.4` 第 9 章,MVP 上线前必须满足:
|
||||
|
||||
| 验收项 | 标准 | 测量方式 | 责任方 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -494,14 +498,30 @@ sequenceDiagram
|
||||
- [ ] H5 端在 iOS/Android/微信内置浏览器通过兼容性测试
|
||||
- [ ] 安全扫描无高危漏洞
|
||||
|
||||
### 9.4 本地测试命令与超时建议
|
||||
|
||||
**后端全量测试(单测 + API):**
|
||||
```
|
||||
cd backend
|
||||
./venv/bin/pytest tests -q
|
||||
```
|
||||
> 说明:全量测试在本机可能耗时 **3~5 分钟**,执行器/CI 请预留 **≥ 300s** 超时预算。
|
||||
|
||||
**后端集成测试(Docker):**
|
||||
```
|
||||
cd backend
|
||||
./venv/bin/pytest tests/test_health_integration.py -q -m integration -vv
|
||||
```
|
||||
> 说明:需要本机 Docker 正常运行且当前用户有 docker socket 访问权限。
|
||||
|
||||
---
|
||||
|
||||
## 10. 下一步行动 (Next Steps)
|
||||
|
||||
1. **架构师:** 确认 `Database Schema` (特别是 Brief 规则与审核报告的 JSON 结构)。
|
||||
2. **UI 设计师:** 优先输出 **"达人端 H5 上传页"**(含防锁屏提示)和 **"代理商 PC 审核台"** 的高保真原型。
|
||||
2. **UI 设计师:** 优先输出 **"达人端任务详情上传区"**(含防锁屏提示)和 **"代理商 PC 审核台"** 的高保真原型。
|
||||
3. **AI 工程师:** 搭建 **Logo 向量检索系统** (Grounding DINO + pgvector),验证相似度匹配效果。
|
||||
4. **AI 工程师:** 调试 **Brief 解析流水线** (Layout Analysis + VLM),确保能提取 PDF 中的参考图片。
|
||||
4. **AI 工程师:** 调试 **Brief 解析流水线** (Layout Analysis + VLM),确保能提取结构化规则。
|
||||
5. **后端工程师:** 搭建 FastAPI 框架骨架,集成 Celery 异步队列,对接弹性 GPU 服务。
|
||||
6. **前端工程师:** 验证 Wake Lock API 在 iOS Safari / 微信内置浏览器的兼容性。
|
||||
7. **QA:** 准备 AI 模型测试集(违禁词、Logo、Brief 样本)。
|
||||
@@ -518,6 +538,6 @@ sequenceDiagram
|
||||
| User_Role_Interfaces.md | 界面规范 |
|
||||
| tasks.md | 开发任务清单 |
|
||||
| **featuredoc/tdd_plan.md** | **TDD 实施计划(核心规范)** |
|
||||
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.0)** |
|
||||
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.1)** |
|
||||
| 数据字典 | 待编写 |
|
||||
| API 接口规范 | 待编写 |
|
||||
|
||||
+128
-11
@@ -1,6 +1,9 @@
|
||||
# 设计文档矛盾与模糊点分析 (V1.2)
|
||||
# 设计文档矛盾与模糊点分析 (V1.7)
|
||||
|
||||
本文档用于记录并跟踪 `SmartAudit` 项目各设计文档之间的矛盾点与模糊点。以下条目均已对齐并给出统一结论。
|
||||
本文档用于记录并跟踪 `秒思智能审核平台` 项目各设计文档之间的矛盾点与模糊点。
|
||||
|
||||
**最后检查时间:** 2026-02-03
|
||||
**检查版本:** PRD V1.0, RequirementsDoc V1.0, FeatureSummary V1.6, DevelopmentPlan V1.6, tasks.md V1.6, UIDesignSpec V1.0, UIDesign V1.1, User_Role_Interfaces V1.5, AIProviderConfig V2.1, tdd_plan V1.0
|
||||
|
||||
---
|
||||
|
||||
@@ -11,28 +14,32 @@
|
||||
- **对齐文档:** `DevelopmentPlan.md`、`User_Role_Interfaces.md`
|
||||
|
||||
### 1.2 Logo 检测职责边界 (已澄清)
|
||||
- **结论:** 视觉模型仅用于画面语义/场景风险分析;竞品 Logo 检测由内置 CV 模型处理,不受配置影响。
|
||||
- **对齐文档:** `AIProviderConfig.md`、`UIDesign.md`、`User_Role_Interfaces.md`
|
||||
- **结论:** 视觉模型仅用于画面语义/场景风险分析;竞品 Logo 检测由内置 CV 模型(Grounding DINO + Vector DB)处理,不受配置影响。
|
||||
- **对齐文档:** `AIProviderConfig.md`、`UIDesign.md`、`User_Role_Interfaces.md`、`DevelopmentPlan.md`
|
||||
|
||||
### 1.3 文件上传方案 (已同步)
|
||||
- **结论:** 批量上传采用 **多文件拖拽并发上传 + Tus 断点续传**,弃用 ZIP。
|
||||
- **对齐文档:** `PRD.md`、`RequirementsDoc.md`、`FeatureSummary.md`、`DevelopmentPlan.md`
|
||||
|
||||
### 1.4 AI 厂商配置范围 (已统一)
|
||||
- **结论:** 每租户仅配置**单一 AI 提供商**(OneAPI/OpenRouter 中转或直连厂商),不做自动故障转移;需要切换由管理员手动更新配置。
|
||||
- **对齐文档:** `AIProviderConfig.md`、`PRD.md`、`RequirementsDoc.md`、`FeatureSummary.md`、`DevelopmentPlan.md`
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限与工作流
|
||||
|
||||
### 2.1 强制通过与特例关系 (已统一)
|
||||
- **结论:** “强制通过”弹窗内提供 **保存为特例** 勾选项(默认不勾选);勾选后生成豁免条款并等待品牌方确认生效。
|
||||
- **结论:** "强制通过"弹窗内提供 **保存为特例** 勾选项(默认不勾选);勾选后生成豁免条款并等待品牌方确认生效。
|
||||
- **对齐文档:** `PRD.md`、`UIDesign.md`、`User_Role_Interfaces.md`
|
||||
|
||||
### 2.2 强制通过禁用后的流程 (已统一)
|
||||
- **结论:** 品牌方关闭授权后,代理商端按钮文案变为“申请强制通过”,填写理由并提交品牌方审批。
|
||||
- **结论:** 品牌方关闭授权后,代理商端按钮文案变为"申请强制通过",填写理由并提交品牌方审批。
|
||||
- **对齐文档:** `PRD.md`、`UIDesign.md`、`User_Role_Interfaces.md`
|
||||
|
||||
### 2.3 AI 配置可见性 (已统一)
|
||||
- **结论:** 代理商/达人 **不可见** AI 配置,仅品牌方管理员可查看与修改。
|
||||
- **对齐文档:** `UIDesign.md`、`User_Role_Interfaces.md`
|
||||
- **对齐文档:** `UIDesign.md`、`User_Role_Interfaces.md`、`AIProviderConfig.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -50,14 +57,124 @@
|
||||
|
||||
## 4. 文档一致性
|
||||
|
||||
### 4.1 TDD 计划文档缺失 (已补齐)
|
||||
### 4.1 TDD 计划文档 (已补齐)
|
||||
- **结论:** 新增 `featuredoc/tdd_plan.md`,作为 `tasks.md` 中 TDD 引用的正式文档。
|
||||
- **对齐文档:** `tasks.md`
|
||||
- **对齐文档:** `tasks.md`、`DevelopmentPlan.md`
|
||||
|
||||
### 4.2 视频采样率假设 (已同步)
|
||||
- **结论:** CV 采样率默认 **2fps**,并在该采样率下验证时长统计准确性。
|
||||
- **对齐文档:** `PRD.md`、`FeatureSummary.md`
|
||||
- **对齐文档:** `PRD.md`、`FeatureSummary.md`、`DevelopmentPlan.md`
|
||||
|
||||
### 4.3 UI 设计风格 (已统一)
|
||||
- **问题:** 存在两份 UI 设计文档,风格不一致:
|
||||
- `UIDesign.md`: Apple Human Interface Guidelines **浅色系** (#FFFFFF 为主背景)
|
||||
- `UIDesignSpec.md`: Apple-style **暗色主题** (#0B0B0E 为页面背景)
|
||||
- **结论:** 以 `UIDesignSpec.md` 和设计稿 `pencil-new.pen` 为准,采用 **暗色主题**。
|
||||
- **已处理:** 在 `UIDesign.md` 头部添加废弃声明,指向 `UIDesignSpec.md` 为正式规范。
|
||||
- **对齐文档:** `UIDesign.md`、`UIDesignSpec.md`
|
||||
- **备注:** `tasks.md` 中的 UIDesign.md 引用保持不变,因 UIDesign.md 中的设计原则和交互说明仍有参考价值;具体颜色/组件规范以 UIDesignSpec.md 为准。
|
||||
|
||||
### 4.4 审计日志不可篡改实现 (已明确)
|
||||
- **结论:** 采用 append-only + hash chain(前序哈希 + 当前内容)保证审计日志可追溯与不可篡改。
|
||||
- **对齐文档:** `PRD.md`、`RequirementsDoc.md`、`FeatureSummary.md`、`DevelopmentPlan.md`、`tasks.md`
|
||||
|
||||
---
|
||||
|
||||
**当前状态:** 无待决策项。
|
||||
## 5. 数值与指标一致性 (已验证)
|
||||
|
||||
### 5.1 功能优先级数量
|
||||
| 优先级 | PRD | FeatureSummary | DevelopmentPlan | tasks.md | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P0 (MVP) | 12项 (场景级) | 23 | 21 | 23 | ✅ 一致(新增 F-51, F-52) |
|
||||
| P1 | 4项 (场景级) | 22 | - | 22 | ✅ 一致 |
|
||||
| P2 | 2项 (场景级) | 8 | - | 8 | ✅ 一致 |
|
||||
|
||||
### 5.2 技术指标
|
||||
| 指标 | PRD | FeatureSummary | DevelopmentPlan | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 审核报告产出时间 | ≤ 5 分钟(含排队 ≤ 2 分钟) | ≤ 5 分钟(含排队 ≤ 2 分钟) | ≤ 5 分钟(含排队 ≤ 2 分钟) | ✅ 一致 |
|
||||
| 竞品 Logo F1 | ≥ 0.85 | ≥ 0.85 | ≥ 0.85 | ✅ 一致 |
|
||||
| ASR 字错率 | ≤ 10% | ≤ 10% | ≤ 10% | ✅ 一致 |
|
||||
| OCR 准确率 | ≥ 95% | ≥ 95% | ≥ 95% | ✅ 一致 |
|
||||
| 语境理解误报率 | ≤ 5% | ≤ 5% | ≤ 5% | ✅ 一致 |
|
||||
| 时长统计误差 | ≤ 1秒 | ≤ 1秒 | ≤ 1秒 | ✅ 一致 |
|
||||
| 频次统计准确率 | ≥ 95% | ≥ 95% | ≥ 95% | ✅ 一致 |
|
||||
| 加密方案 | AES-256-GCM | AES-256-GCM | AES-256-GCM | ✅ 一致 |
|
||||
| CV 采样率 | 2fps | 2fps | 2fps | ✅ 一致 |
|
||||
|
||||
### 5.3 开发周期
|
||||
| 项目 | DevelopmentPlan | tasks.md | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 总周期 | 11 周 | 11 周 | ✅ 一致 |
|
||||
| Phase 1 | Week 1-2 | Week 1-2 | ✅ 一致 |
|
||||
| Phase 2 | Week 3-6 (4周) | Week 3-6 (4周) | ✅ 一致 |
|
||||
| Phase 3 | Week 7-9 | Week 7-9 | ✅ 一致 |
|
||||
| Phase 4 | Week 10-11 | Week 10-11 | ✅ 一致 |
|
||||
|
||||
### 5.4 测试覆盖率要求
|
||||
| 类型 | DevelopmentPlan | tdd_plan.md | tasks.md | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 后端覆盖率 | ≥ 80% | ≥ 80% | ≥ 80% | ✅ 一致 |
|
||||
| 前端覆盖率 | ≥ 70% | ≥ 70% | ≥ 70% | ✅ 一致 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 合理性评估 (已验证)
|
||||
|
||||
### 6.1 技术方案合理性
|
||||
- ✅ **前后端分离 + AI 微服务化**:适合视频处理的高算力需求
|
||||
- ✅ **FastAPI + Celery + Redis**:Python 生态成熟,适合 AI 集成
|
||||
- ✅ **PostgreSQL + pgvector**:减少架构复杂度,统一向量检索
|
||||
- ✅ **Tus 协议**:解决大文件上传不稳定问题
|
||||
- ✅ **弹性 GPU 集群**:支持自动扩缩容,控制成本
|
||||
|
||||
### 6.2 时间估算合理性
|
||||
- ✅ **Phase 2 延长至 4 周**:预留多模态时间戳对齐的工程时间
|
||||
- ✅ **Phase 3 移动端 + 审核台**:3 周合理
|
||||
- ✅ **Phase 4 联调验收**:2 周合理
|
||||
|
||||
### 6.3 功能优先级合理性
|
||||
- ✅ **F-09 语境理解提升至 P0**:避免"人工智障"体验
|
||||
- ✅ **F-17 进度展示提升至 P0**:缓解等待焦虑
|
||||
- ✅ **F-05 拆分为 A/B**:MVP 聚焦核心防竞品能力
|
||||
- ✅ **F-45 时长频次校验 P0**:满足 Brief 硬性指标
|
||||
- ✅ **F-47/48/49 AI 配置 P0**:AI 服务基础设施
|
||||
|
||||
---
|
||||
|
||||
## 7. 已完成处理项
|
||||
|
||||
### 7.1 UI 设计文档统一 ✅
|
||||
**状态:** 已完成 (2026-02-03)
|
||||
**描述:** `UIDesign.md`(浅色系)与 `UIDesignSpec.md`(暗色主题)风格冲突
|
||||
**决策:** 以 `UIDesignSpec.md` 和 `pencil-new.pen` 为准(暗色主题)
|
||||
**已完成行动:**
|
||||
1. ✅ 在 `UIDesign.md` 头部添加废弃声明,指向 `UIDesignSpec.md` 为正式规范
|
||||
2. ✅ `tasks.md` 中的引用保持不变(设计原则部分仍有参考价值)
|
||||
|
||||
### 7.2 AIProviderConfig 文档引用更新 ✅
|
||||
**状态:** 已完成 (2026-02-03)
|
||||
**描述:** `AIProviderConfig.md` 第7章引用了已废弃的 `UIDesign.md`
|
||||
**已完成行动:**
|
||||
- ✅ 更新引用为 `User_Role_Interfaces.md` 第 4.6 章
|
||||
|
||||
### 7.3 tdd_plan.md 版本号补充 ✅
|
||||
**状态:** 已完成 (2026-02-03)
|
||||
**描述:** `tdd_plan.md` 缺少版本号和文档头部元信息
|
||||
**已完成行动:**
|
||||
- ✅ 添加文档头部元信息,版本号 V1.0
|
||||
|
||||
---
|
||||
|
||||
**当前状态:** ✅ 无待决策项。所有文档一致性问题已解决,可开始开发。
|
||||
|
||||
---
|
||||
|
||||
## 8. 检查历史
|
||||
|
||||
| 检查时间 | 版本 | 检查人 | 发现问题 | 处理结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2026-02-03 | V1.4 | Claude | UI设计风格不一致、版本号引用 | 已统一 |
|
||||
| 2026-02-03 | V1.5 | Claude | AIProviderConfig引用过时、tdd_plan缺版本号 | 已修复 |
|
||||
| 2026-02-03 | V1.6 | Claude | 新增 F-51 品牌方终审开关、F-52 审核流程进度可视化 | 已同步至全部文档 |
|
||||
| 2026-02-03 | V1.7 | Claude | F-52 扩展为全角色(达人/代理商/品牌方)可见 | 已同步:FeatureSummary、tasks.md、User_Role_Interfaces、UI设计 |
|
||||
|
||||
+276
-103
@@ -2,9 +2,9 @@
|
||||
|
||||
| 文档类型 | **Feature Summary (产品功能文档)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.3 |
|
||||
| **发布日期** | 2026-02-03 |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.7 |
|
||||
| **发布日期** | 2026-02-05 |
|
||||
| **关联文档** | RequirementsDoc.md, PRD.md, User_Role_Interfaces.md |
|
||||
| **侧重** | 功能清单、优先级、验收标准、界面映射、边界说明 |
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
| V1.1 | 2026-02-02 | Claude | 根据 Gemini 修订意见调整:补充验收标准、Out of Scope、核心痛点细化 |
|
||||
| V1.2 | 2026-02-02 | Claude | 根据 Gemini 关键改进意见:优先级调整、功能拆分、新增功能、移动端适配 |
|
||||
| V1.3 | 2026-02-02 | Claude | **新增 AI 厂商动态配置功能模块 (F-47~F-50)**,支持数据库配置、多租户隔离 |
|
||||
| V1.4 | 2026-02-03 | Claude | 文档一致性修订:AI 配置单提供商模式、审计日志不可篡改方案、版本号更新 |
|
||||
| V1.5 | 2026-02-03 | Claude | **新增 F-51 品牌方终审开关、F-52 审核流程进度可视化** |
|
||||
| V1.6 | 2026-02-03 | Claude | **F-52 扩展为全角色可见**:代理商端(桌面+移动)、品牌方端(桌面+移动)均可查看进度 |
|
||||
| V1.7 | 2026-02-05 | Claude | **明确两阶段审核流程**:脚本阶段+视频阶段;完善任务按钮状态逻辑(查看详情→上传视频);添加历史任务归档规则(当日00:00自动归档) |
|
||||
|
||||
**Gemini 修订意见采纳情况:**
|
||||
|
||||
@@ -37,7 +41,7 @@
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定位为**"智能预审员"**,在人工介入前**自动化拦截 80% 的基础错误和合规风险**,将审核流转周期从"天"缩短到"小时"。
|
||||
秒思智能审核平台 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定位为**"智能预审员"**,在人工介入前**自动化拦截 80% 的基础错误和合规风险**,将审核流转周期从"天"缩短到"小时"。
|
||||
|
||||
### 1.2 核心价值
|
||||
|
||||
@@ -64,7 +68,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 功能架构 │
|
||||
│ 秒思智能审核平台 功能架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
@@ -78,8 +82,8 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 数据看板 │ │ 规则配置 │ │ 审计日志 │ │
|
||||
│ │ │ │ 舆情预警 │ │ 证据导出 │ │
|
||||
│ │ 数据看板 │ │ 规则配置 │ │ 证据导出 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
@@ -201,7 +205,15 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
**核心价值:** 避免拍完重拍的巨大沉没成本
|
||||
|
||||
**界面映射:** 达人端 → 智能上传页
|
||||
**关键功能:**
|
||||
- 标题与任务信息:任务名、平台、截止时间、当前步骤(脚本)
|
||||
- 文件上传(支持 PDF/Word/纯文本/Excel)
|
||||
- 关键提示:脚本提交后进入 AI 预审,结果回到任务详情
|
||||
- 提交校验:空内容禁止提交
|
||||
- 草稿保存:支持本地或后端草稿
|
||||
- 等待代理商审核态(脚本已通过):任务详情展示当前阶段、进度条高亮、脚本提交信息、AI 结果摘要与消息中心提醒
|
||||
|
||||
**界面映射:** 达人端 → 任务详情页(上传区)
|
||||
|
||||
---
|
||||
|
||||
@@ -214,7 +226,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
- 原内容:"全网第一"
|
||||
- AI建议:建议改为"深受喜爱"或"销量领先"
|
||||
|
||||
**界面映射:** 达人端 → 审核结果页 → 修改清单
|
||||
**界面映射:** 达人端 → 任务详情-审核结果区 → 修改清单
|
||||
|
||||
---
|
||||
|
||||
@@ -228,7 +240,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
**为什么是 P0:** 如果 MVP 版本把"我**最**开心的一天"误判为广告法极限词违规,达人会认为这个 AI 是"人工智障",导致口碑崩盘。这是用户体验的底线。
|
||||
|
||||
**界面映射:** 达人端 → 审核结果页
|
||||
**界面映射:** 达人端 → 任务详情-审核结果区
|
||||
|
||||
---
|
||||
|
||||
@@ -256,7 +268,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
- 分辨率支持 1080p
|
||||
- 格式支持 MP4/MOV
|
||||
|
||||
**界面映射:** 达人端 → 智能上传页
|
||||
**界面映射:** 达人端 → 任务详情页(上传区)
|
||||
|
||||
---
|
||||
|
||||
@@ -296,7 +308,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
**界面映射:**
|
||||
- 代理商端 → 审核决策台 → 智能进度条
|
||||
- 达人端 → 审核结果页 → 时间轴跳转
|
||||
- 达人端 → 任务详情-审核结果区 → 时间轴跳转
|
||||
|
||||
---
|
||||
|
||||
@@ -346,7 +358,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
**功能描述:** 在等待期间显示 AI 处理进度。
|
||||
|
||||
**展示示例:**
|
||||
- 🔍 正在解析 Brief 核心卖点...
|
||||
- 🔍 正在加载任务规则...
|
||||
- 👁️ 正在逐帧检测竞品 Logo...
|
||||
- 🧠 正在分析口播情感色彩...
|
||||
|
||||
@@ -354,7 +366,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
**为什么是 P0:** 视频上传+审核通常需要 3-5 分钟。如果 MVP 只有一个旋转的"Loading"图标而没有具体的文字进度,用户会以为死机了而关闭页面,导致用户流失。
|
||||
|
||||
**界面映射:** 达人端 → 智能上传页 → 透明思考 UI
|
||||
**界面映射:** 达人端 → 任务详情页(上传区) → 透明思考 UI
|
||||
|
||||
---
|
||||
|
||||
@@ -362,7 +374,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
|
||||
**功能描述:** 审核完成后提供带时间戳的修改清单。
|
||||
|
||||
**界面映射:** 达人端 → 审核结果页 → 修改清单
|
||||
**界面映射:** 达人端 → 任务详情-审核结果区 → 修改清单
|
||||
|
||||
---
|
||||
|
||||
@@ -372,6 +384,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-19 | 风险列表展示 | P0 | US-08 | 代理商 |
|
||||
| F-20 | 确认/驳回操作 | P0 | US-08 | 代理商 |
|
||||
| F-51 | 品牌方终审开关 | **P0** | - | 品牌方 |
|
||||
| F-21 | 强制通过权 | P1 | US-09 | 品牌方(默认授权代理商) |
|
||||
| F-22 | 特例记录与白名单 | P1 | US-09 | 品牌方 |
|
||||
| F-23 | 规则依据与证据查看 | P1 | US-08 | 代理商/品牌方 |
|
||||
@@ -394,13 +407,146 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
**功能描述:** 审核员只需点击确认或驳回,无需从头看视频。
|
||||
|
||||
**操作说明:**
|
||||
- 驳回:自动将勾选的问题打包发送给达人
|
||||
- 通过:流程结束
|
||||
- 驳回:自动将勾选的问题打包发送给达人;任务回到「脚本上传」阶段并触发脚本 AI 预审,再进入代理商复审 →(可选)品牌终审(可循环)
|
||||
- 通过:
|
||||
- 若品牌方**未开启终审**(默认)→ 流程结束,任务状态变为「已通过」
|
||||
- 若品牌方**已开启终审** → 进入品牌方终审队列,任务状态变为「待终审」
|
||||
|
||||
**界面映射:** 代理商端 → 审核决策台 → 决策栏
|
||||
|
||||
---
|
||||
|
||||
#### F-51 品牌方终审开关 ⭐ P0 (新增)
|
||||
|
||||
**功能描述:** 品牌方可配置是否对代理商初审通过的内容进行终审。
|
||||
|
||||
**配置选项:**
|
||||
- **终审开关**:开启/关闭(**默认关闭**)
|
||||
- **终审范围**:全部内容 / 仅舆情风险内容 / 仅指定代理商
|
||||
- **终审超时处理**:超时自动通过 / 超时提醒(默认48小时)
|
||||
|
||||
**流程说明:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 终审关闭(默认) │
|
||||
│ 达人提交 → AI审核 → 代理商初审通过 → ✅ 最终通过 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 终审开启 │
|
||||
│ 达人提交 → AI审核 → 代理商初审通过 → 品牌方终审 │
|
||||
│ ├─ 通过 → ✅ 最终通过 │
|
||||
│ └─ 驳回 → 返回脚本上传 │
|
||||
│ ↘ │
|
||||
│ 脚本AI预审 → 代理商复审 →(可选)终审 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**为什么是 P0:** 审核流程是系统核心逻辑,需在 MVP 阶段确定流程框架,即使默认关闭也需要支持配置能力。
|
||||
|
||||
**界面映射:** 品牌方端 → 系统设置 → 审核流程配置
|
||||
|
||||
---
|
||||
|
||||
#### F-52 审核流程进度可视化 ⭐ P0 (新增)
|
||||
|
||||
**功能描述:** **全角色(达人、代理商、品牌方)** 均可在移动端和桌面端实时查看内容的完整审核流程状态,清晰了解当前处于哪个审核阶段。
|
||||
|
||||
> **重要:** 每个任务包含「脚本阶段」和「视频阶段」两轮审核,进度条需体现当前所处阶段
|
||||
|
||||
**两阶段审核流程:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 两阶段审核流程(脚本阶段 + 视频阶段) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 【脚本阶段】 │
|
||||
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 📝 │ ──▶│ 🤖 AI │──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│ │
|
||||
│ │脚本上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │ │
|
||||
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ ▼ ▼ │
|
||||
│ │ ❌ 不通过 ❌ 驳回 ❌ 驳回 │
|
||||
│ │ │ │ │ │
|
||||
│ └───────────┴──────────────┴──────────────┘ │
|
||||
│ ↑ 重新提交脚本 │
|
||||
│ │
|
||||
│ 【脚本通过 → 进入视频阶段】 │
|
||||
│ │
|
||||
│ 【视频阶段】 │
|
||||
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 📹 │ ──▶│ 🤖 AI │──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│ │
|
||||
│ │视频上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │ │
|
||||
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ ▼ ▼ │
|
||||
│ │ ❌ 不通过 ❌ 驳回 ❌ 驳回 │
|
||||
│ │ │ │ │ │
|
||||
│ └───────────┴──────────────┴──────────────┘ │
|
||||
│ ↑ 重新上传视频(无需重新提交脚本) │
|
||||
│ │
|
||||
│ 最终状态:✅ 审核通过 → 当日00:00后自动归入历史记录 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**任务列表按钮状态逻辑:**
|
||||
| 当前状态 | 任务卡片按钮 | 点击行为 |
|
||||
| --- | --- | --- |
|
||||
| 脚本通过(首次) | [查看详情] | 进入结果页,显示「下一步:上传视频」 |
|
||||
| 脚本通过(已查看) | [上传视频] | 直接进入视频上传页 |
|
||||
| 视频通过 | [查看结果] | 进入结果页,显示「审核通过,可发布」 |
|
||||
| 脚本/视频驳回 | [查看修改意见] | 进入结果页查看驳回原因 |
|
||||
|
||||
**结果页按钮逻辑:**
|
||||
| 阶段 | 审核结果 | 按钮文案 |
|
||||
| --- | --- | --- |
|
||||
| 脚本阶段 | 通过 | [下一步:上传视频] |
|
||||
| 脚本阶段 | 驳回 | [重新提交脚本] |
|
||||
| 视频阶段 | 通过 | [审核通过,可发布] |
|
||||
| 视频阶段 | 驳回 | [重新上传视频] |
|
||||
|
||||
**状态定义(两阶段审核):**
|
||||
|
||||
**脚本阶段状态:**
|
||||
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 待提交脚本 | 📝 | 灰色 | 等待达人上传脚本 | [上传脚本] |
|
||||
| 脚本AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析脚本 | [审核中...] |
|
||||
| 脚本需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
|
||||
| 脚本待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
|
||||
| 脚本代理商驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
|
||||
| 脚本待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
|
||||
| 脚本品牌方驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
|
||||
| 脚本通过 | ✅ | 绿色 | 脚本通过,待上传视频 | [查看详情] 或 [上传视频]* |
|
||||
|
||||
> *首次显示 [查看详情],进入结果页点击「下一步:上传视频」后变为 [上传视频]
|
||||
|
||||
**视频阶段状态:**
|
||||
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 待上传视频 | 📹 | 灰色 | 等待达人上传视频 | [上传视频] |
|
||||
| 视频AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析视频 | [审核中...] |
|
||||
| 视频需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
|
||||
| 视频待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
|
||||
| 视频代理商驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
|
||||
| 视频待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
|
||||
| 视频品牌方驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
|
||||
| 审核通过 | ✅ | 绿色 | 视频通过,可发布 | [查看结果] |
|
||||
| 已归档 | 📁 | 灰色 | 当日00:00后自动归档 | [查看记录] |
|
||||
|
||||
**为什么是 P0:** 达人最关心"我的内容现在在哪个环节",清晰的流程状态能减少达人焦虑,避免频繁询问代理商,提升用户体验。代理商和品牌方也需要在审核时清楚了解内容当前所处阶段。
|
||||
|
||||
**界面映射:**
|
||||
| 角色 | 端 | 页面 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 达人 | 桌面 | 任务列表、任务详情-审核结果区 | 卡片状态标签 + 顶部进度条 |
|
||||
| 达人 | 移动 | 任务列表、任务详情-审核结果区 | 卡片状态标签 + 顶部进度条 |
|
||||
| 代理商 | 桌面 | 审核决策台 | 顶部进度条,标注"当前:代理商审核" |
|
||||
| 代理商 | 移动 | 快捷审核 | 导航栏下方进度条 |
|
||||
| 品牌方 | 桌面 | 终审台 | 顶部进度条,标注"当前:品牌终审" |
|
||||
| 品牌方 | 移动 | 审批中心 | 审批项内显示进度条 |
|
||||
|
||||
---
|
||||
|
||||
#### F-21 强制通过权
|
||||
|
||||
**功能描述:** 品牌方可手动放行过于保守的误报(如达人玩的新梗)。**默认授权代理商独立使用强制通过功能**;品牌方可在设置中**按代理商**关闭授权,关闭后代理商需发起审批流程。
|
||||
@@ -408,7 +554,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
**约束条件:**
|
||||
- 必须填写放行原因
|
||||
- 弹窗提供“保存为特例”勾选项(勾选后生成豁免条款,需品牌方确认)
|
||||
- 记录审批人与操作时间,纳入审计日志
|
||||
- 记录审批人与操作时间
|
||||
|
||||
**界面映射:** 代理商端 → 审核决策台 → 决策栏 → [强制通过]
|
||||
|
||||
@@ -443,32 +589,69 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-24 | 发起申诉 | P1 | - | 达人 |
|
||||
| F-25 | 申诉令牌管理 | P1 | - | 系统 |
|
||||
| F-25 | 申诉次数管理 | P1 | - | 达人/代理商 |
|
||||
| F-25-A | 申请增加申诉次数 | P1 | - | 达人 |
|
||||
| F-25-B | 处理申诉次数请求 | P1 | - | 代理商 |
|
||||
| F-26 | 人工仲裁 | P1 | - | 代理商 |
|
||||
| F-27 | 申诉结果通知 | P1 | - | 达人 |
|
||||
|
||||
#### F-24 发起申诉
|
||||
|
||||
**功能描述:** 达人可对每条报错发起申诉。
|
||||
**功能描述:** 达人可对每条报错发起申诉,消耗该任务的申诉次数。
|
||||
|
||||
**操作要求:**
|
||||
- 提供理由输入框(必填,≥ 10 字)
|
||||
- 可上传补充证据(截图、链接等)
|
||||
- 消耗申诉令牌
|
||||
- 消耗该任务的1次申诉次数
|
||||
- 若申诉次数为0,提示"申诉次数不足"并引导申请增加
|
||||
|
||||
**界面映射:** 达人端 → 审核结果页 → [申诉] 按钮
|
||||
**界面映射:** 达人端 → 任务详情-审核结果区 → [申诉] 按钮
|
||||
|
||||
---
|
||||
|
||||
#### F-25 申诉令牌管理
|
||||
#### F-25 申诉次数管理
|
||||
|
||||
**功能描述:** 基于达人信用评分分配令牌配额,申诉成功后令牌返还。
|
||||
**功能描述:** 每个任务独立拥有申诉次数,初始为1次,达人可向代理商申请增加。
|
||||
|
||||
**规则说明:**
|
||||
- 历史表现越好,配额越高
|
||||
- 申诉成功后令牌自动返还
|
||||
- 每个任务初始申诉次数:**1次**
|
||||
- 申诉次数按任务独立计算,不同任务互不影响
|
||||
- 达人可在个人中心查看所有任务的申诉次数
|
||||
- 代理商可为达人的任务增加申诉次数(无上限)
|
||||
|
||||
**界面映射:** 达人端 → 审核结果页 → 申诉弹窗(显示剩余令牌)
|
||||
**界面映射:**
|
||||
- 达人端 → 个人中心 → 申诉次数(查看所有任务的申诉次数列表)
|
||||
- 达人端 → 任务详情-审核结果区 → 申诉弹窗(显示当前任务剩余次数)
|
||||
|
||||
---
|
||||
|
||||
#### F-25-A 申请增加申诉次数
|
||||
|
||||
**功能描述:** 达人可为特定任务向代理商申请增加申诉次数。
|
||||
|
||||
**申请流程:**
|
||||
1. 达人在个人中心 → 申诉次数页面,点击某任务的 `[申请增加]` 按钮
|
||||
2. 无需填写理由,直接发送申请
|
||||
3. 代理商在消息中心收到通知
|
||||
4. 等待代理商处理(同意/拒绝/忽略)
|
||||
|
||||
**界面映射:** 达人端 → 个人中心 → 申诉次数 → [申请增加] 按钮
|
||||
|
||||
---
|
||||
|
||||
#### F-25-B 处理申诉次数请求
|
||||
|
||||
**功能描述:** 代理商处理达人的申诉次数增加请求。
|
||||
|
||||
**处理方式:**
|
||||
- **消息中心入口:** 收到通知"达人【XXX】申请增加【XXX任务】的申诉次数",点击可处理
|
||||
- **达人管理入口:** 在达人管理页面可主动为任意达人的任务增加申诉次数
|
||||
- **操作选项:** 同意(+1次)/ 拒绝 / 不处理(忽略)
|
||||
- **增加上限:** 无上限
|
||||
|
||||
**界面映射:**
|
||||
- 代理商端 → 消息中心 → 申诉次数请求通知
|
||||
- 代理商端 → 达人管理 → 达人详情 → 任务申诉次数管理
|
||||
|
||||
---
|
||||
|
||||
@@ -610,95 +793,87 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
|
||||
---
|
||||
|
||||
### 3.8 审计日志与证据导出
|
||||
### 3.8 项目管理
|
||||
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-38 | 审核记录查询 | P1 | US-12 | 品牌方 |
|
||||
| F-39 | 完整审核链路查看 | P1 | US-12 | 品牌方 |
|
||||
| F-40 | 证据链 PDF 导出 | P1 | US-12 | 品牌方/代理商 |
|
||||
| F-54 | 创建项目 | P0 | - | 品牌方 |
|
||||
|
||||
#### F-38 审核记录查询
|
||||
#### F-54 创建项目 ⭐ 新增
|
||||
|
||||
**功能描述:** 查看所有审核记录,支持高级筛选(时间/代理商/达人/结果)。
|
||||
**功能描述:** 品牌方创建营销项目,配置项目基本信息并分配给代理商执行。
|
||||
|
||||
**界面映射:** 品牌方端 → 审计日志 → 列表视图
|
||||
**创建项目字段:**
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 项目名称 | 文本输入 | 必填,项目的名称标识 |
|
||||
| 截止日期 | 日期选择 | 必填,项目交付截止时间 |
|
||||
| 品牌Brief | 文件上传 | 必填,支持 PDF/Word/Excel/PPT 格式,最大 50MB |
|
||||
| 选择代理商 | 卡片多选 | 必填,从已添加的代理商中选择(卡片式展示,支持多选) |
|
||||
|
||||
**核心流程:**
|
||||
1. 品牌方点击侧边栏「创建项目」进入创建项目页面
|
||||
2. 填写项目名称、选择截止日期
|
||||
3. 上传品牌Brief文档(支持拖拽上传)
|
||||
4. 从已添加的代理商卡片中勾选参与的代理商
|
||||
5. 点击 `[创建项目]` 完成创建
|
||||
6. 系统向选中的代理商发送项目分配通知
|
||||
|
||||
**为什么是 P0:** 项目是整个审核流程的起点,没有项目创建功能,代理商无法接收任务。
|
||||
|
||||
**界面映射:** 品牌方端 → 侧边栏 → 创建项目 → 创建项目页面 (fP5rY)
|
||||
|
||||
---
|
||||
|
||||
#### F-39 完整审核链路查看
|
||||
|
||||
**功能描述:** 点击任意记录查看完整审核链路,包含原始视频、AI 报告、人工决策、申诉记录。
|
||||
|
||||
**界面映射:** 品牌方端 → 审计日志 → 详情页
|
||||
|
||||
---
|
||||
|
||||
#### F-40 证据链 PDF 导出
|
||||
|
||||
**功能描述:** 生成符合法务要求的 PDF 报告。
|
||||
|
||||
**报告内容:**
|
||||
- 时间戳:所有操作的精确时间记录
|
||||
- 截图:AI 报错对应的视频截图
|
||||
- 规则依据:触发的规则版本与具体条款
|
||||
- 审核人:操作人身份与电子签名
|
||||
- 规则版本号、模型版本号
|
||||
- 完整操作日志(不可篡改)
|
||||
|
||||
**界面映射:** 品牌方端 → 审计日志 → 证据链导出
|
||||
|
||||
---
|
||||
|
||||
### 3.9 舆情预警中心
|
||||
### 3.9 代理商管理
|
||||
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-41 | 舆情风险视频监控 | P2 | US-06 | 品牌方 |
|
||||
| F-42 | 舆情案例库 | P2 | - | 品牌方 |
|
||||
| F-43 | 舆情阈值设置 | P1 | US-10B | 品牌方 |
|
||||
| F-44 | 代理商ID与邀请 | P0 | - | 品牌方/代理商 |
|
||||
|
||||
#### F-41 舆情风险视频监控
|
||||
#### F-44 代理商ID与邀请
|
||||
|
||||
**功能描述:** 近期被 AI 标记为"舆情风险"的视频列表,按风险等级排序。
|
||||
**功能描述:** 每个代理商拥有系统唯一的代理商ID(如 `AG123456`),品牌方通过搜索代理商ID发起邀请,代理商在消息中心接受邀请后加入品牌方代理商列表。
|
||||
|
||||
**界面映射:** 品牌方端 → 舆情预警中心 → 实时监控
|
||||
**核心流程:**
|
||||
1. 品牌方点击「邀请代理商」,输入代理商ID搜索
|
||||
2. 系统显示匹配的代理商信息(头像、名称、代理商ID)
|
||||
3. 品牌方确认后发送邀请
|
||||
4. 代理商在消息中心收到邀请通知,可选择「同意」或「拒绝」
|
||||
5. 代理商同意后自动加入品牌方的代理商列表,可接收项目分配
|
||||
|
||||
**附加功能:** 管理合作代理商与授权范围(可见 Brief 范围、**按代理商**强制通过授权、申诉仲裁权限、绩效评分卡)。
|
||||
|
||||
**界面映射:**
|
||||
- 品牌方端 → 代理商管理 → 邀请代理商弹窗
|
||||
- 代理商端 → 消息中心 → 品牌方邀请通知
|
||||
|
||||
---
|
||||
|
||||
#### F-42 舆情案例库
|
||||
|
||||
**功能描述:** 历史舆情事件归档,作为培训素材供代理商学习。
|
||||
|
||||
**界面映射:** 品牌方端 → 舆情预警中心 → 案例库
|
||||
|
||||
---
|
||||
|
||||
#### F-43 舆情阈值设置
|
||||
|
||||
**功能描述:** 调整 AI 对"油腻"、"性感"、"争议话题"的敏感度,支持按平台差异化配置。
|
||||
|
||||
**重要约束:** 舆情风险仅作提示,不作为强制拦截依据
|
||||
|
||||
**界面映射:** 品牌方端 → 规则配置 → 舆情阈值设置
|
||||
|
||||
---
|
||||
|
||||
### 3.10 代理商管理
|
||||
### 3.10 达人管理
|
||||
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-44 | 代理商管理 | P1 | - | 品牌方 |
|
||||
| F-53 | 达人ID与邀请 | P0 | - | 代理商/达人 |
|
||||
|
||||
#### F-44 代理商管理
|
||||
#### F-53 达人ID与邀请
|
||||
|
||||
**功能描述:** 管理合作代理商与授权范围(可见 Brief 范围、**按代理商**强制通过授权、申诉仲裁权限、绩效评分卡)。
|
||||
**功能描述:** 每个达人拥有系统唯一的达人ID(如 `CR123456`),代理商通过搜索达人ID发起邀请,达人在消息中心接受邀请后加入代理商达人列表。
|
||||
|
||||
**界面映射:** 品牌方端 → 代理商管理
|
||||
**核心流程:**
|
||||
1. 代理商点击「邀请达人」,输入达人ID搜索
|
||||
2. 系统显示匹配的达人信息(头像、昵称、达人ID)
|
||||
3. 代理商确认后发送邀请
|
||||
4. 达人在消息中心收到邀请通知,可选择「同意」或「拒绝」
|
||||
5. 达人同意后自动加入代理商的达人列表,可接收任务分配
|
||||
|
||||
**界面映射:**
|
||||
- 代理商端 → 达人管理 → 邀请达人弹窗
|
||||
- 达人端 → 消息中心 → 代理商邀请通知
|
||||
|
||||
---
|
||||
|
||||
### 3.11 AI 闭环学习 (新增)
|
||||
### 3.11 AI 闭环学习
|
||||
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
@@ -706,7 +881,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
|
||||
---
|
||||
|
||||
### 3.12 系统管理 - AI 厂商配置 (V1.4 新增)
|
||||
### 3.12 系统管理 - AI 厂商配置 (V1.4 修订)
|
||||
|
||||
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
@@ -717,13 +892,13 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
|
||||
#### F-47 AI 厂商动态配置 ⭐ P0
|
||||
|
||||
**功能描述:** 品牌方管理员可在后台配置多个 AI 厂商(DeepSeek、OpenAI、通义千问、OneAPI 中转等),配置存储在数据库中,运行时动态加载,无需修改代码或重启服务。
|
||||
**功能描述:** 品牌方管理员可在后台配置**单一 AI 提供商**(OneAPI/OpenRouter 中转或直连厂商),配置存储在数据库中,运行时动态加载,无需修改代码或重启服务。每个租户仅保留一套配置,可随时切换提供商。
|
||||
|
||||
**核心功能:**
|
||||
- 支持添加、编辑、删除 AI 厂商配置
|
||||
- 支持配置与更新 AI 提供商
|
||||
- 配置 Base URL、API Key(AES-256-GCM 加密存储)、默认模型
|
||||
- 为不同使用场景(Brief 解析、脚本预审、视频审核)指定不同厂商
|
||||
- 配置优先级和备用厂商(故障转移)
|
||||
- 为不同使用场景(Brief 解析、脚本预审、视频审核)指定不同模型
|
||||
- 可通过 OneAPI/OpenRouter 聚合多模型
|
||||
- 未配置时阻断调用并提示品牌方完成配置
|
||||
|
||||
**为什么是 P0:** 这是 AI 服务的基础设施,所有 AI 功能都依赖此配置。
|
||||
@@ -744,7 +919,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
|
||||
#### F-49 多租户 AI 配置隔离
|
||||
|
||||
**功能描述:** 不同品牌方可配置独立的 AI 厂商,实现租户级别的配置隔离和配额管理。
|
||||
**功能描述:** 不同品牌方可配置独立的 AI 提供商,实现租户级别的配置隔离和配额管理。
|
||||
|
||||
**界面映射:** 品牌方后台 → 系统设置 → AI 配置
|
||||
|
||||
@@ -799,14 +974,17 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| F-17 | 审核进度实时展示 | 视频审核 | ⭐ P1→P0,缓解等待焦虑 |
|
||||
| F-19 | 风险列表展示 | 审核台 | |
|
||||
| F-20 | 确认/驳回操作 | 审核台 | |
|
||||
| F-51 | 品牌方终审开关 | 审核台 | ⭐ 新增,审核流程可配置 |
|
||||
| F-52 | 审核流程进度可视化 | 达人端 | ⭐ 新增,达人可见审核状态 |
|
||||
| F-33 | 核心指标卡片 | 数据看板 | |
|
||||
| F-54 | 创建项目 | 项目管理 | ⭐ 新增,项目是审核流程的起点 |
|
||||
| F-47 | AI 厂商动态配置 | 系统管理 | ⭐ V1.3 新增,AI 基础设施 |
|
||||
| F-48 | AI 厂商连通性测试 | 系统管理 | ⭐ V1.3 新增 |
|
||||
| F-49 | 多租户 AI 配置隔离 | 系统管理 | ⭐ V1.3 新增 |
|
||||
|
||||
### 4.2 V1.1 (P1) - 首版后快速迭代
|
||||
|
||||
**P1 共 22 个功能(表中区间行如 F-24~27、F-34~36、F-38~40 为合并展示)**
|
||||
**P1 共 16 个功能(表中区间行如 F-24~27、F-34~36 为合并展示)**
|
||||
|
||||
| 功能编号 | 功能名称 | 模块 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -821,8 +999,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| F-24~27 | 申诉与仲裁 | 申诉 | |
|
||||
| F-34~36 | 趋势图表与预警 | 数据看板 | |
|
||||
| F-37 | 达人排行榜 | 数据报表 | |
|
||||
| F-38~40 | 审计日志与证据导出 | 审计 | |
|
||||
| F-43 | 舆情阈值设置 | 舆情 | |
|
||||
| F-44 | 代理商管理 | 系统管理 | |
|
||||
| F-50 | API Key 轮换管理 | 系统管理 | ⭐ V1.3 新增 |
|
||||
|
||||
@@ -835,7 +1011,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| F-30~32 | 批量上传/审核/导出 | 批量处理 | F-30 改为多文件拖拽 |
|
||||
| F-28 | 版本差异报告 | 版本比对 | |
|
||||
| F-29 | 双屏同步播放 | 版本比对 | |
|
||||
| F-41~42 | 舆情监控与案例库 | 舆情 | |
|
||||
| F-46 | 负样本清洗与回流 | AI 闭环 | ⭐ 新增,让 AI 真正学习 |
|
||||
|
||||
---
|
||||
@@ -854,8 +1029,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| 数据看板 | 个人进度 | 项目/达人 | 全局 |
|
||||
| 规则配置 | ❌ | ❌ | ✅ |
|
||||
| 代理商管理 | ❌ | ❌ | ✅ |
|
||||
| 审计日志 | ❌ | 所管辖 | 全部 |
|
||||
| 舆情预警 | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -868,7 +1041,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| **安全** | 传输与存储加密(AES-256-GCM);基于角色权限控制;关键操作二次确认 |
|
||||
| **隐私** | 数据最小化;默认保留 30 天;符合《个保法》与 GDPR |
|
||||
| **数据本地化** | 国内客户数据存储于中国大陆境内服务器 |
|
||||
| **审计** | 操作日志可审计且不可篡改 |
|
||||
| **审计** | 操作日志可审计且不可篡改(append-only + hash chain) |
|
||||
| **移动端适配** | **达人端(上传/查看报告)必须适配移动端 H5 竖屏操作** |
|
||||
|
||||
> ⚠️ **移动端适配说明:** 达人的工作场景多在拍摄现场(移动端),需要在手机上完成脚本上传、查看审核结果等操作。如果只做 PC 网页版,达人无法在拍摄现场即时使用,产品价值会大打折扣。
|
||||
@@ -925,7 +1098,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
|
||||
| RequirementsDoc.md | 业务需求文档(用户故事、成功指标) |
|
||||
| PRD.md | 产品需求文档(功能需求、技术架构) |
|
||||
| User_Role_Interfaces.md | 用户角色与界面规范 |
|
||||
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.0)** |
|
||||
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.1)** |
|
||||
| 技术设计文档 (TDD) | 待编写 |
|
||||
| API 接口规范 | 待编写 |
|
||||
| 数据字典 | 待编写 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| 文档类型 | **PRD (Product Requirement Document)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.0 |
|
||||
| **发布日期** | 2026-02-03 |
|
||||
| **状态** | 草稿 (Draft) |
|
||||
@@ -102,11 +102,12 @@
|
||||
- 视频自动审核(竞品、违禁词、画面风险) → [US-05]
|
||||
- 审核台风险打点与确认/驳回 → [US-08]
|
||||
- 语境理解降低误报 → [US-04]
|
||||
- 审核进度展示与时间戳修改清单 → [US-07]
|
||||
- 审核进度展示(F-17) → [US-07]
|
||||
- 基础黑白名单与竞品库(F-05-A) → [US-10A]
|
||||
- 时长与频次校验(F-45) → [US-05]
|
||||
- AI 服务配置与连通性测试(F-47/F-48)
|
||||
- 多租户 AI 配置隔离(F-49)
|
||||
- 数据看板核心指标卡片(F-33)
|
||||
|
||||
**P1(首版发布后快速迭代)**
|
||||
- Brand Safety 软性风险提示 → [US-06]
|
||||
@@ -130,10 +131,11 @@
|
||||
- **舆情风控雷达:** 针对"油腻感"、"价值观风险"、"错别字"的专项检测
|
||||
- **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面
|
||||
- **移动端支持:** 响应式 H5 覆盖达人/代理商/品牌方(可作为小程序 WebView 承载)
|
||||
- **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程
|
||||
- **信用与申诉体系:** 包含按任务独立的申诉次数管理和人工仲裁流程
|
||||
- **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置
|
||||
- **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离
|
||||
- **审计日志与报告导出:** 支持导出可追溯的审核证据链
|
||||
- **证据链导出:** 支持导出可追溯的审核证据链报告
|
||||
- **数据看板与核心指标:** 提供核心指标卡片与基础数据概览
|
||||
|
||||
### 5.2 Out of Scope
|
||||
|
||||
@@ -175,9 +177,11 @@
|
||||
### 6.2 脚本预审 (Pre-production) [US-03, US-04]
|
||||
|
||||
**P0**
|
||||
- 支持文本脚本提交与预审
|
||||
- 支持脚本文档上传与预审(PDF/Word/纯文本/Excel)
|
||||
- 输出违规项、遗漏卖点、建议修改
|
||||
- 帮助达人在拍摄前发现问题,避免拍完重拍的沉没成本
|
||||
- 脚本提交后进入 AI 预审,结果回到任务详情
|
||||
- 空内容禁止提交,支持草稿保存(本地或后端)
|
||||
|
||||
**P0**
|
||||
- 语境理解降低误报(区分广告语境与日常语境)
|
||||
@@ -218,15 +222,23 @@
|
||||
**P0**
|
||||
- 审核台展示风险列表(红/黄/绿分级)与时间戳
|
||||
- 支持确认/驳回操作,无需从头看视频
|
||||
- **可配置审核流程(F-51)**:品牌方可开启/关闭终审环节
|
||||
- **终审关闭(默认)**:代理商初审通过 → 最终通过
|
||||
- **终审开启**:代理商初审通过 → 品牌方终审 → 通过;驳回则回到脚本上传
|
||||
- **驳回回路**(代理商/品牌方):驳回后任务回到「脚本上传」阶段 → 触发脚本 AI 预审 → 代理商复审 →(如开启)品牌终审;未通过则重复循环
|
||||
- 支持配置终审范围(全部/仅舆情风险/指定代理商)
|
||||
- 支持配置终审超时处理(默认48小时)
|
||||
|
||||
**P1**
|
||||
- 品牌方"强制通过权":可手动放行过于保守的误报(需记录原因与审批人);**默认授权代理商独立使用,可在品牌方设置中按代理商关闭,关闭后需走审批流程**。强制通过弹窗需填写原因,并提供“保存为特例”可选项(**默认不勾选**,勾选后形成豁免条款,需品牌方确认生效)
|
||||
- 品牌方"强制通过权":可手动放行过于保守的误报(需记录原因与审批人);**默认授权代理商独立使用,可在品牌方设置中按代理商关闭,关闭后需走审批流程**。强制通过弹窗需填写原因,并提供"保存为特例"可选项(**默认不勾选**,勾选后形成豁免条款,需品牌方确认生效)
|
||||
- 特例可沉淀为规则白名单/豁免条款(含来源:强制通过勾选或手动记录)
|
||||
- 如需用于模型优化,必须确保数据授权与合规评估
|
||||
- 可查看规则依据与证据片段
|
||||
|
||||
**验收要点**
|
||||
- 每条结论包含规则版本、模型版本、证据截图/片段与时间戳
|
||||
- 终审开启时,代理商通过后任务状态变为「待终审」
|
||||
- 品牌方驳回或代理商驳回时,任务回到「脚本上传」阶段并重新进入 AI → 代理商 →(可选)品牌的复核流程
|
||||
|
||||
### 6.5 代理商管理
|
||||
|
||||
@@ -243,9 +255,11 @@
|
||||
### 6.6 申诉与仲裁
|
||||
|
||||
**P1**
|
||||
- 申诉令牌管理与工单流转
|
||||
- 按任务独立的申诉次数管理(每任务初始1次)
|
||||
- 达人申请增加申诉次数流程
|
||||
- 代理商处理申诉次数请求(同意/拒绝)
|
||||
- 人工仲裁流程与记录
|
||||
- 审计日志完整可追溯
|
||||
- 操作记录完整可追溯
|
||||
|
||||
### 6.7 版本差异与批量处理 [US-11, US-13]
|
||||
|
||||
@@ -269,34 +283,80 @@
|
||||
|
||||
---
|
||||
|
||||
### 6.9 数据看板与核心指标
|
||||
|
||||
**P0**
|
||||
- 核心指标卡片(F-33):展示审核总量、初审通过率、硬性召回率、舆情拦截数、平均审核周期
|
||||
|
||||
**P1**
|
||||
- 趋势图表(F-34):近 30 天审核量与通过率趋势
|
||||
- 风险预警(F-35):竞品露出集中爆发、达人连续未通过、舆情异常上升
|
||||
- 代理商绩效对比(F-36)与达人排行榜(F-37)
|
||||
|
||||
**验收要点**
|
||||
- 指标口径与成功指标定义一致
|
||||
- 指标数据可追溯到审计记录
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键流程 (Key User Flows)
|
||||
|
||||
### 7.1 品牌方工作流
|
||||
|
||||
1. 制定并下达 Brief 投放要求
|
||||
2. 配置品牌私有规则(禁用词、竞品列表、白名单)
|
||||
3. 抽查最终视频审核报告
|
||||
4. 处理严重争议与风险决策
|
||||
5. 行使"强制通过权"处理误报
|
||||
6. 导出审核证据链用于合规归档
|
||||
1. 邀请代理商:通过代理商ID搜索并发送邀请,代理商同意后加入品牌方代理商列表
|
||||
2. 创建项目并分配给代理商
|
||||
3. 制定并下达 Brief 投放要求
|
||||
4. 配置品牌私有规则(禁用词、竞品列表、白名单)
|
||||
5. 抽查最终视频审核报告
|
||||
6. 处理严重争议与风险决策
|
||||
7. 行使"强制通过权"处理误报
|
||||
8. 导出审核证据链用于合规归档
|
||||
|
||||
### 7.2 代理商工作流
|
||||
|
||||
1. 创建任务并上传 Brief
|
||||
2. 系统解析 Brief 并生成规则集
|
||||
3. 创建达人任务并发起脚本预审
|
||||
4. 达人上传视频,系统自动审核
|
||||
5. 审核员在审核台确认/驳回(基于红/黄/绿风险标记)
|
||||
6. 进行人工仲裁(如有争议)
|
||||
7. 导出报告与证据链
|
||||
**代理商ID:** 每个代理商拥有系统唯一的代理商ID(如 `AG123456`),用于品牌方精准邀请。代理商需先接受品牌方邀请才能接收项目分配。
|
||||
|
||||
### 7.3 达人工作流
|
||||
1. 接收品牌方分配的项目(项目出现在Brief配置列表的"待配置"中)
|
||||
2. 配置Brief:上传Brief文件,系统解析并生成规则集
|
||||
3. 邀请达人:通过达人ID搜索并发送邀请,达人同意后加入代理商达人列表
|
||||
4. 分配达人到项目
|
||||
5. 达人在任务详情页提交脚本文档,脚本 AI 预审通过后进入等待代理商审核状态
|
||||
6. 达人在任务详情页补充视频,系统自动审核
|
||||
7. 审核员在审核台确认/驳回(基于红/黄/绿风险标记)
|
||||
8. 若代理商/品牌方驳回:任务回到脚本上传阶段,重新进入脚本 AI → 代理商 →(可选)品牌流程
|
||||
9. 进行人工仲裁(如有争议)
|
||||
10. 导出报告与证据链
|
||||
|
||||
1. 上传脚本进行预审
|
||||
2. 根据建议修改并提交视频
|
||||
3. 查看 AI 审核进度(如"正在核对口播...")
|
||||
4. 收到带时间戳的修改清单
|
||||
5. 触发申诉或修改再提交
|
||||
### 7.3 达人工作流(两阶段审核)
|
||||
|
||||
**达人ID:** 每个达人拥有系统唯一的达人ID(如 `CR123456`),用于代理商精准邀请。达人需先接受代理商邀请才能接收任务。
|
||||
|
||||
**脚本阶段:**
|
||||
1. 进入任务详情上传脚本文档进行预审
|
||||
2. 等待脚本 AI 审核,查看审核进度
|
||||
3. 若脚本 AI 审核不通过:查看修改意见,点击「重新提交脚本」重新上传
|
||||
4. 脚本 AI 通过后,任务详情显示"等待代理商审核"状态
|
||||
5. 若代理商/品牌方驳回脚本:点击「重新提交脚本」重新上传脚本
|
||||
6. 脚本审核通过后,任务列表显示「查看详情」按钮
|
||||
|
||||
**视频阶段:**
|
||||
7. **首次**点击「查看详情」进入结果页,查看脚本通过详情
|
||||
8. 点击「下一步:上传视频」返回任务列表,此时按钮变为「上传视频」
|
||||
9. 点击「上传视频」进入视频上传页,上传视频文件
|
||||
10. 等待视频 AI 审核,查看审核进度(如"正在核对口播...")
|
||||
11. 若视频 AI 审核不通过:查看修改清单,点击「重新上传视频」重新上传
|
||||
12. 视频 AI 通过后,等待代理商/品牌方审核
|
||||
13. 若代理商/品牌方驳回视频:点击「重新上传视频」重新上传视频
|
||||
14. 视频审核通过后,点击「审核通过,可发布」完成任务
|
||||
|
||||
**历史归档:**
|
||||
15. 当日 00:00 后,已通过任务自动归入历史记录
|
||||
|
||||
**申诉流程:**
|
||||
- 对任意审核结论可触发申诉(消耗该任务的申诉次数)
|
||||
- 每个任务初始申诉次数为 **1次**,不同任务独立计算
|
||||
- 申诉次数不足时,可向代理商申请增加(无需理由)
|
||||
- 代理商可在消息中心或达人管理页面处理申请(同意/拒绝/忽略)
|
||||
|
||||
---
|
||||
|
||||
@@ -306,7 +366,7 @@
|
||||
| --- | --- | --- |
|
||||
| 品牌方(含品牌方管理员) | 品牌内任务与规则 | 强制通过、规则管理、报告导出、私有规则配置、AI 服务商配置与管理 |
|
||||
| 代理商 | 代理商管理范围 | 任务创建、审核确认/驳回、批量处理、人工仲裁、强制通过(按代理商授权,默认开启,可关闭) |
|
||||
| 达人 | 自己的任务 | 上传脚本/视频、查看报告、申诉 |
|
||||
| 达人 | 自己的任务 | 在任务详情上传脚本/视频、查看报告、申诉 |
|
||||
|
||||
---
|
||||
|
||||
@@ -319,13 +379,14 @@
|
||||
- **规则集**:平台规则 + 品牌私有规则 + 白名单 + 规则版本记录
|
||||
- **审核记录**:风险项、时间戳、证据片段、风险等级(红/黄/绿)
|
||||
- **人工决策**:确认/驳回/强制通过 + 操作人 + 操作时间
|
||||
- **申诉记录**:申诉原因、仲裁结论、令牌消耗
|
||||
- **申诉记录**:申诉原因、仲裁结论、申诉次数变化(任务维度)
|
||||
|
||||
### 9.2 审计要求 [US-12]
|
||||
|
||||
- 全流程日志可追溯、不可篡改
|
||||
- 导出报告包含规则版本、模型版本、证据截图/片段与时间戳
|
||||
- 支持争议场景下完整审核证据链导出
|
||||
- 操作日志采用 append-only + hash chain(前序哈希 + 当前内容)确保可追溯
|
||||
|
||||
---
|
||||
|
||||
@@ -394,13 +455,13 @@
|
||||
- **ASR/OCR**:支持普通话及主流方言的语音识别,支持复杂背景字幕识别
|
||||
- **计算机视觉**:Logo 检测、物体识别、场景分类
|
||||
- **消息队列**:异步处理视频审核任务,支持优先级调度
|
||||
- **AI 厂商动态配置**:品牌方管理员可在后台配置多个 AI 厂商(DeepSeek/OpenAI/OneAPI 等),运行时动态加载,支持多租户隔离和故障转移(详见 AIProviderConfig.md)
|
||||
- **AI 厂商动态配置**:品牌方管理员可在后台配置**单一 AI 提供商**(可为 OneAPI/OpenRouter 中转或直连厂商),运行时动态加载,支持多租户隔离;配置变更可随时切换(详见 AIProviderConfig.md)
|
||||
|
||||
---
|
||||
|
||||
## 14. 里程碑与发布计划 (Milestones)
|
||||
|
||||
- **MVP (P0)**:Brief 解析、规则加载、脚本预审、视频审核、审核台、语境理解降低误报、审核进度展示、基础黑白名单与竞品库、时长与频次校验、AI 服务商配置
|
||||
- **MVP (P0)**:Brief 解析、规则加载、脚本预审、视频审核、审核台、语境理解降低误报、审核进度展示、基础黑白名单与竞品库、时长与频次校验、AI 服务商配置、核心指标卡片
|
||||
- **V1.1 (P1)**:Brand Safety 提示、规则版本、证据链导出、强制通过权、高级豁免规则
|
||||
- **V2 (P2)**:批量处理、版本差异报告
|
||||
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
| 文档类型 | **RD (Requirements Document)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.0 |
|
||||
| **发布日期** | 2026-02-03 |
|
||||
| **状态** | **修订 (Revised)** |
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
### 4.2 场景二:脚本预审 (Pre-production)
|
||||
|
||||
* **[US-03] [P0]** 作为 **达人**,我希望在拍摄前先提交文字脚本进行预审,让系统帮我检查是否遗漏了卖点或触犯了广告法,避免拍完重拍的巨大沉没成本。
|
||||
* **[US-03] [P0]** 作为 **达人**,我希望在拍摄前先通过**脚本文档上传**(PDF/Word/纯文本/Excel)提交脚本进行预审,让系统帮我检查是否遗漏了卖点或触犯了广告法,避免拍完重拍的巨大沉没成本;脚本 AI 通过后任务进入**等待代理商审核**状态并在任务详情提示;若代理商或品牌方驳回,任务回到**脚本上传**阶段并重新进入 AI → 代理商 →(可选)品牌的复核流程(可循环)。
|
||||
* **[US-04] [P0]** 作为 **达人**,我希望审核系统能"读懂上下文",不要因为我在讲故事时说了"最开心的一天"就报"广告极限词违规",减少对创作的干扰。
|
||||
|
||||
### 4.3 场景三:视频智能审核 (Post-production)
|
||||
@@ -131,10 +131,11 @@
|
||||
3. **分区执法逻辑:** 智能区分“广告段”与“剧情段”,应用不同的审核尺度。
|
||||
4. **舆情风控雷达:** 针对“油腻感”、“价值观风险”、“错别字”的专项检测模型。
|
||||
5. **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面。
|
||||
6. **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程。
|
||||
6. **信用与申诉体系:** 包含按任务独立的申诉次数管理(每任务初始1次,可向代理商申请增加)和人工仲裁流程。
|
||||
7. **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置。
|
||||
8. **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离。
|
||||
9. **审计日志与报告导出:** 支持导出可追溯的审核证据链。
|
||||
10. **数据看板与核心指标:** 提供核心指标卡片与基础数据概览。
|
||||
|
||||
### ❌ Out of Scope (本期不做)
|
||||
|
||||
@@ -188,7 +189,7 @@
|
||||
* **ASR/OCR:** 支持普通话及主流方言的语音识别,支持复杂背景字幕识别
|
||||
* **计算机视觉:** Logo 检测、物体识别、场景分类
|
||||
* **消息队列:** 异步处理视频审核任务,支持优先级调度
|
||||
* **AI 厂商动态配置:** 支持在数据库中配置多个 AI 厂商(DeepSeek/OpenAI/OneAPI 等),运行时动态加载,支持多租户隔离和故障转移(详见 AIProviderConfig.md)
|
||||
* **AI 厂商动态配置:** 支持在数据库中配置**单一 AI 提供商**(可为 OneAPI/OpenRouter 中转或直连厂商),运行时动态加载,支持多租户隔离;配置变更可随时切换(详见 AIProviderConfig.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -213,6 +214,7 @@
|
||||
* **性能:** ≤ 100MB 视频上传后,AI 预审报告产出时间不超过 5 分钟(含排队 ≤ 2 分钟)。
|
||||
* **审计链路:** 每条结论包含规则版本、模型版本、证据截图/片段与时间戳。
|
||||
* **F-45 时长与频次统计:** 时长统计误差 ≤ 1秒;频次统计准确率 ≥ 95%。
|
||||
* **审计日志不可篡改:** 采用 append-only + hash chain(前序哈希 + 当前内容)校验可追溯。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+53
-34
@@ -1,12 +1,23 @@
|
||||
# UIDesign.md - 智能视频审核系统 UI 设计规范
|
||||
|
||||
> ⚠️ **重要说明 (2026-02-03)**
|
||||
>
|
||||
> 本文档为**早期设计参考文档**,设计风格为浅色系。
|
||||
>
|
||||
> **当前正式设计规范请参考:[UIDesignSpec.md](./UIDesignSpec.md)**
|
||||
> - 设计稿文件:`pencil-new.pen`
|
||||
> - 设计风格:Apple-style **暗色主题**
|
||||
> - 包含最新的设计令牌、组件规范和页面清单
|
||||
>
|
||||
> 本文档仅保留用于历史参考和设计原则说明。开发时以 `UIDesignSpec.md` 为准。
|
||||
|
||||
| 文档类型 | **UI Design System (设计系统规范)** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.1 |
|
||||
| **发布日期** | 2026-02-03 |
|
||||
| **设计风格** | Apple Human Interface Guidelines 浅色系 |
|
||||
| **关联文档** | PRD.md, FeatureSummary.md, User_Role_Interfaces.md, DevelopmentPlan.md |
|
||||
| **设计风格** | ~~Apple Human Interface Guidelines 浅色系~~ → 已更新为暗色主题,见 UIDesignSpec.md |
|
||||
| **关联文档** | PRD.md, FeatureSummary.md, User_Role_Interfaces.md, DevelopmentPlan.md, **UIDesignSpec.md** |
|
||||
|
||||
---
|
||||
|
||||
@@ -16,22 +27,23 @@
|
||||
| --- | --- | --- | --- |
|
||||
| V1.0 | 2026-02-02 | Claude | 初稿:设计原则、色彩系统、组件库、三端界面规范 |
|
||||
| V1.1 | 2026-02-02 | Claude | 新增第10章:AI 服务配置界面设计规范 |
|
||||
| V1.2 | 2026-02-03 | Claude | 添加废弃声明,指向 UIDesignSpec.md(暗色主题) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则 (Design Principles)
|
||||
|
||||
借鉴 Apple Human Interface Guidelines,SmartAudit 的设计遵循以下核心原则:
|
||||
借鉴 Apple Human Interface Guidelines,秒思智能审核平台 的设计遵循以下核心原则:
|
||||
|
||||
### 1.1 核心设计理念
|
||||
|
||||
| 原则 | 描述 | 在 SmartAudit 中的体现 |
|
||||
| 原则 | 描述 | 在 秒思智能审核平台 中的体现 |
|
||||
| --- | --- | --- |
|
||||
| **Clarity (清晰)** | 文字清晰易读,图标精确传意,功能显而易见 | 审核结论用红/黄/绿 + 文字 + 图标三重表达 |
|
||||
| **Deference (克制)** | UI 退居幕后,内容为王 | 审核台以视频和报告为核心,界面元素轻量化 |
|
||||
| **Depth (层次)** | 通过视觉层次和流畅动效建立空间感 | 卡片悬浮阴影、模态弹窗、进度条层叠 |
|
||||
|
||||
### 1.2 SmartAudit 专属原则
|
||||
### 1.2 秒思智能审核平台 专属原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -409,7 +421,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🏠 📤 🔔 👤 │
|
||||
│ 任务 上传 消息 我的 │
|
||||
│ 任务 消息 我的 │
|
||||
│ │
|
||||
│ ━━━━━━ ──── ──── ──── │
|
||||
│ (选中态) (3) │
|
||||
@@ -458,11 +470,11 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.4 智能上传页 (透明思考 UI)
|
||||
### 7.4 任务详情页 - 上传区 (透明思考 UI)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ◀ 返回 上传视频 │
|
||||
│ ◀ 返回 任务详情 · 上传 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ╭─────────────╮ │
|
||||
@@ -482,7 +494,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
│ │
|
||||
│ 👁️ 正在逐帧检测竞品 Logo... │
|
||||
│ │
|
||||
│ ✅ Brief 解析完成 00:05 │
|
||||
│ ✅ 任务规则加载完成 00:05 │
|
||||
│ ✅ ASR 语音转写完成 00:23 │
|
||||
│ ◐ Logo 检测中... 进行中 │
|
||||
│ ○ 语义分析 等待中 │
|
||||
@@ -509,7 +521,14 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
- 底部提供"离开"选项,减少用户被困感
|
||||
- 上传时显示防锁屏提示(Wake Lock API)
|
||||
|
||||
### 7.5 审核结果页
|
||||
**脚本上传功能要点(任务详情内):**
|
||||
- 标题与任务信息:任务名、平台、截止时间、当前步骤(脚本)
|
||||
- 文件上传(PDF/Word/纯文本/Excel)
|
||||
- 关键提示:脚本提交后进入 AI 预审,结果回到任务详情
|
||||
- 提交按钮 + 校验:空内容禁止提交
|
||||
- 草稿保存:支持本地或后端草稿保存
|
||||
|
||||
### 7.5 任务详情-审核结果区
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@@ -572,12 +591,21 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**等待代理商审核态(脚本已通过)显示:**
|
||||
- 任务信息头部:任务名、平台、截止时间、当前阶段(“等待代理商审核”)
|
||||
- 审核流程进度条:当前阶段高亮,已完成阶段打勾
|
||||
- 脚本提交信息:文件名/类型(PDF/Word/纯文本/Excel)、提交时间
|
||||
- AI 脚本预审结果摘要:结论通过、简短说明、软性提示(Warn-only)以提示样式展示
|
||||
- 等待提示:显示“已进入代理商审核,请耐心等待”
|
||||
- 结果告知:提示后续结果将在消息中心提醒
|
||||
|
||||
**设计要点:**
|
||||
- 顶部结果横幅用语义色背景,一目了然
|
||||
- 视频进度条上标注问题时间点(红/黄点)
|
||||
- 点击时间点可跳转视频对应位置
|
||||
- 每条问题提供"跳转"和"申诉"两个操作
|
||||
- 软性提示明确标注"不影响通过"
|
||||
- 代理商/品牌方驳回:结果横幅显示“未通过”,主操作为“重新上传脚本”;任务回到脚本上传并触发脚本 AI 预审 → 代理商复审 →(可选)品牌终审(可循环)
|
||||
|
||||
### 7.6 申诉弹窗
|
||||
|
||||
@@ -612,8 +640,8 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
│ │ │ │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 剩余申诉令牌:2 次 │ │
|
||||
│ │ 申诉成功后令牌将自动返还 │ │
|
||||
│ │ 💡 本任务剩余申诉次数:1 次 │ │
|
||||
│ │ 每个任务独立计算,次数不足可向代理商申请增加 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 提交申诉 │ │ │
|
||||
@@ -641,7 +669,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
│ │
|
||||
│ ┌──────────┐ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │ │ │
|
||||
│ │ SmartAudit│ │ 页面内容区域 │ │
|
||||
│ │ 秒思智能审核平台│ │ 页面内容区域 │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├──────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
@@ -680,7 +708,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 张三 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 张三 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 工作台 │ 工作台 │
|
||||
@@ -727,7 +755,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 张三 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 张三 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 工作台 │ Brief 配置中心 │
|
||||
@@ -769,7 +797,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 张三 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 张三 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 工作台 │ 审核决策台 ⬅️ ➡️ │
|
||||
@@ -789,16 +817,9 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
│ │ │ │ 置信度 92% │ │
|
||||
│ ⚙️ 设置 │ │ ──────────────────── │ [展开详情] [查看截图] │ │
|
||||
│ │ │ 🔴 🔴 🟡 │ │ │
|
||||
│ │ │ ▼ ▼ ▼ │ Brief 完成度 │ │
|
||||
│ │ │ ▼ ▼ ▼ │ 舆情雷达 │ │
|
||||
│ │ │ ░░░░░░░░░░░░░░░░░░░░ │ ───────────────────────────── │ │
|
||||
│ │ │ 00:00 02:30 │ │ │
|
||||
│ │ │ │ ✅ 卖点1:美白 │ │
|
||||
│ │ │ ┌──────────────────┐ │ ✅ 卖点2:补水 │ │
|
||||
│ │ │ │ │ │ ❌ 卖点3:24小时持妆 (未提及) │ │
|
||||
│ │ │ │ Brief 参考图 │ │ │ │
|
||||
│ │ │ │ (画中画悬浮) │ │ 舆情雷达 │ │
|
||||
│ │ │ │ │ │ ───────────────────────────── │ │
|
||||
│ │ │ └──────────────────┘ │ │ │
|
||||
│ │ │ │ 🟡 01:28 油腻风险 (仅提示) │ │
|
||||
│ │ │ │ 达人表情过于夸张 │ │
|
||||
│ │ │ │ │ │
|
||||
@@ -818,11 +839,10 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
**设计要点:**
|
||||
- 左侧 60%:视频播放器 + 智能进度条(红/黄/绿点)
|
||||
- 右侧 40%:AI 检查单,分为"硬性合规"、"Brief 完成度"、"舆情雷达"三区
|
||||
- 右侧 40%:AI 检查单,分为"硬性合规"、"舆情雷达"两区
|
||||
- 底部固定操作栏,三个决策按钮
|
||||
- 品牌方**按代理商**关闭授权时,“强制通过”按钮改为“申请强制通过”,点击弹出原因并提交审批
|
||||
- 强制通过弹窗包含“保存为特例”勾选项(默认不勾选),勾选后生成豁免条款并等待品牌方确认
|
||||
- Brief 参考图可悬浮在视频角落对比
|
||||
|
||||
### 8.6 版本比对视窗
|
||||
|
||||
@@ -892,7 +912,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 王总 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 王总 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 数据 │ 数据看板 本月 ▼ 导出报告 ▼ │
|
||||
@@ -936,7 +956,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 王总 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 王总 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 数据 │ 规则配置 │
|
||||
@@ -998,7 +1018,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartAudit 🔔 (3) 👤 王总 │
|
||||
│ 秒思智能审核平台 🔔 (3) 👤 王总 │
|
||||
├──────────┬─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📊 数据 │ AI 服务配置 │
|
||||
@@ -1411,8 +1431,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
|
||||
| 角色 | 页面名称 | 优先级 | 设计状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| **达人** | 任务列表 | P0 | 待设计 |
|
||||
| | 智能上传页 (透明思考 UI) | P0 | 待设计 |
|
||||
| | 审核结果页 | P0 | 待设计 |
|
||||
| | 任务详情上传区 (透明思考 UI) | P0 | 待设计 |
|
||||
| | 任务详情-审核结果区 | P0 | 待设计 |
|
||||
| | 申诉弹窗 | P1 | 待设计 |
|
||||
| | 消息中心 | P1 | 待设计 |
|
||||
| | 历史记录 | P2 | 待设计 |
|
||||
@@ -1425,9 +1445,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
|
||||
| **品牌方** | 数据看板 | P0 | 待设计 |
|
||||
| | 规则配置 | P0 | 待设计 |
|
||||
| | AI 服务配置 | P0 | 已设计 |
|
||||
| | 审计日志 | P1 | 待设计 |
|
||||
| | 代理商管理 | P1 | 待设计 |
|
||||
| | 舆情预警 | P2 | 待设计 |
|
||||
| | 终审台 | P0 | 已设计 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+136
-44
@@ -2,9 +2,9 @@
|
||||
|
||||
| 文档类型 | **UI Design Specification** |
|
||||
| --- | --- |
|
||||
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
|
||||
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
|
||||
| **版本号** | V1.0 |
|
||||
| **发布日期** | 2026-02-03 |
|
||||
| **发布日期** | 2026-02-05 |
|
||||
| **设计稿文件** | `pencil-new.pen` |
|
||||
| **设计风格** | Apple-style 暗色主题,商业级/高端质感 |
|
||||
|
||||
@@ -81,9 +81,8 @@
|
||||
| 个人中心 | `user` | 底部导航、我的 |
|
||||
| 达人管理 | `users` | 侧边栏导航 |
|
||||
| 数据看板/报表 | `chart-column` | 底部导航、侧边栏 |
|
||||
| 舆情预警 | `triangle-alert` | 侧边栏导航 |
|
||||
| 代理商管理 | `building-2` | 侧边栏导航 |
|
||||
| 审计日志 | `scroll-text` | 侧边栏导航 |
|
||||
| 终审台 | `shield-check` | 侧边栏导航 |
|
||||
| 系统设置 | `settings` | 侧边栏导航 |
|
||||
| Brief管理 | `file-text` | 侧边栏导航 |
|
||||
| 版本比对 | `git-compare` | 侧边栏导航 |
|
||||
@@ -244,53 +243,136 @@
|
||||
|
||||
### 4.1 达人端 (Creator)
|
||||
|
||||
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
|
||||
| --- | --- | --- | --- |
|
||||
| 任务列表 | Mobile | P0 | PjBJD |
|
||||
| 智能上传 | Mobile | P0 | ZelCS |
|
||||
| 审核结果 | Mobile | P0 | Vn3VU |
|
||||
| AI审核中 | Mobile | P0 | lzdm4 |
|
||||
| 消息中心 | Mobile | P1 | pF15t |
|
||||
| 历史记录 | Mobile | P2 | ZKEFl |
|
||||
| 个人中心 | Mobile | P2 | zCdM1 |
|
||||
| 任务列表 | Desktop | P0 | HD3eK |
|
||||
| 智能上传 | Desktop | P0 | N79bL |
|
||||
| 审核结果 | Desktop | P0 | 3niUa |
|
||||
| AI审核中 | Desktop | P0 | bxAKT |
|
||||
| 消息中心 | Desktop | P1 | 8XKLP |
|
||||
> **两阶段审核说明:** 每个任务包含「脚本阶段」和「视频阶段」两轮审核
|
||||
|
||||
#### 4.1.1 Mobile 端页面
|
||||
|
||||
| 页面名称 | 阶段 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 任务列表 | 通用 | P0 | PjBJD | 含历史任务入口 |
|
||||
| 脚本上传区 | 脚本阶段 | P0 | ZelCS | 上传脚本文档 |
|
||||
| 脚本AI审核中 | 脚本阶段 | P0 | lzdm4 | 透明思考UI |
|
||||
| 脚本AI审核通过 | 脚本阶段 | P0 | Vn3VU | 结果页,含「下一步:上传视频」 |
|
||||
| 脚本AI审核不通过 | 脚本阶段 | P0 | cjcZZ | 结果页,含「重新提交脚本」 |
|
||||
| 脚本代理商审核通过 | 脚本阶段 | P0 | IyLsO | 结果页 |
|
||||
| 脚本代理商审核不通过 | 脚本阶段 | P0 | zU3Op | 结果页,含「重新提交脚本」 |
|
||||
| 脚本品牌方审核通过 | 脚本阶段 | P0 | f6T3z | 结果页 |
|
||||
| 脚本品牌方审核不通过 | 脚本阶段 | P0 | NeF4L | 结果页,含「重新提交脚本」 |
|
||||
| 视频上传区 | 视频阶段 | P0 | (待补充) | 上传视频文件 |
|
||||
| 视频AI审核中 | 视频阶段 | P0 | (待补充) | 透明思考UI |
|
||||
| 视频AI审核通过 | 视频阶段 | P0 | (待补充) | 结果页 |
|
||||
| 视频AI审核不通过 | 视频阶段 | P0 | 6EX4Z | 结果页,含「重新上传视频」 |
|
||||
| 视频代理商审核通过 | 视频阶段 | P0 | (待补充) | 结果页 |
|
||||
| 视频代理商审核不通过 | 视频阶段 | P0 | (待补充) | 结果页,含「重新上传视频」 |
|
||||
| 视频品牌方审核通过 | 视频阶段 | P0 | (待补充) | 结果页,含「审核通过,可发布」 |
|
||||
| 视频品牌方审核不通过 | 视频阶段 | P0 | (待补充) | 结果页,含「重新上传视频」 |
|
||||
| 消息中心 | 通用 | P1 | pF15t | 两阶段审核通知 |
|
||||
| 历史记录 | 通用 | P2 | ZKEFl | 当日00:00后自动归档 |
|
||||
| 个人中心 | 通用 | P2 | zCdM1 | - |
|
||||
|
||||
#### 4.1.2 Desktop 端页面
|
||||
|
||||
| 页面名称 | 阶段 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 任务列表 | 通用 | P0 | HD3eK | 含历史任务入口 |
|
||||
| 脚本上传区 | 脚本阶段 | P0 | N79bL | 上传脚本文档 |
|
||||
| 脚本AI审核中 | 脚本阶段 | P0 | bxAKT | 透明思考UI |
|
||||
| 脚本审核结果 | 脚本阶段 | P0 | 3niUa | 通用结果页 |
|
||||
| 消息中心 | 通用 | P1 | 8XKLP | 两阶段审核通知 |
|
||||
|
||||
### 4.2 代理商端 (Agency)
|
||||
|
||||
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
|
||||
> **侧边栏导航顺序:** 工作台 → 审核台 → Brief配置 → 达人管理 → 数据报表 → 消息中心
|
||||
|
||||
#### 4.2.1 Desktop 端页面
|
||||
|
||||
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作台 | Desktop | P0 | RX8V9 |
|
||||
| 审核决策台 | Desktop | P0 | 2u8Bq |
|
||||
| Brief配置中心 | Desktop | P0 | djd2K |
|
||||
| 达人管理 | Desktop | P1 | 5cFMX |
|
||||
| 数据报表 | Desktop | P1 | An8gw |
|
||||
| 版本比对 | Desktop | P2 | NDmYh |
|
||||
| 工作台 | Mobile | P0 | VuH3F |
|
||||
| 快捷审核 | Mobile | P0 | lrHaj |
|
||||
| 任务列表 | Mobile | P1 | c6SPa |
|
||||
| 消息中心 | Mobile | P1 | 9Us9g |
|
||||
| 个人中心 | Mobile | P2 | 8OCZ3 |
|
||||
| 工作台 | P0 | RX8V9 | 待办统计+快捷入口,默认首页 |
|
||||
| 项目详情 | P0 | C7wfV | 项目数据和达人列表 |
|
||||
| 审核台(列表页) | P0 | zjiCT | 脚本/视频待审列表 |
|
||||
| 脚本审核决策台 | P0 | f8HX9 | 简单模式(文件图标+预览按钮) |
|
||||
| 脚本审核(预览模式) | P0 | Wct5R | 展开脚本内容+AI分析 |
|
||||
| 视频审核决策台 | P0 | 2u8Bq | 视频播放+问题标记+决策 |
|
||||
| Brief配置中心(列表页) | P0 | Nicby | 待配置/已配置列表 |
|
||||
| Brief配置详情(待配置) | P0 | jRsW5 | 上传Brief+配置规则 |
|
||||
| Brief配置详情(已配置) | P0 | b06fU | 查看/编辑配置 |
|
||||
| 达人管理 | P1 | 5cFMX | 达人列表+邀请 |
|
||||
| 邀请达人弹窗 | P1 | ADN10 | 邀请达人模态框 |
|
||||
| 数据报表 | P1 | An8gw | 项目数据统计 |
|
||||
| 消息中心 | P1 | PfMR0 | 通知列表 |
|
||||
|
||||
#### 4.2.2 Mobile 端页面
|
||||
|
||||
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作台 | P0 | VuH3F | 紧急待办概览 |
|
||||
| 快捷审核 | P0 | lrHaj | 外出场景审核 |
|
||||
| 任务列表 | P1 | c6SPa | 任务筛选 |
|
||||
| 消息中心 | P1 | 9Us9g | 通知列表 |
|
||||
| 个人中心 | P2 | 8OCZ3 | 个人设置 |
|
||||
|
||||
#### 4.2.3 页面跳转关系
|
||||
|
||||
```
|
||||
侧边栏导航
|
||||
├── 工作台 ───────────► 我的项目 [查看] → 项目详情
|
||||
│ 紧急待办 [审核] → 审核决策台
|
||||
├── 审核台 ───────────► 审核台(列表页)
|
||||
│ ├── 脚本任务 ────► 脚本审核决策台 ─► 预览脚本按钮 → 预览模式
|
||||
│ └── 视频任务 ────► 视频审核决策台
|
||||
├── Brief配置 ────────► Brief配置中心(列表页)
|
||||
│ ├── 待配置项目 ──► Brief配置详情(待配置) ─► 保存 → 已配置列表
|
||||
│ └── 已配置项目 ──► Brief配置详情(已配置)
|
||||
├── 达人管理 ─────────► 邀请按钮 → 邀请达人弹窗
|
||||
├── 数据报表 ─────────► 数据报表页
|
||||
└── 消息中心 ─────────► 消息中心页
|
||||
```
|
||||
|
||||
### 4.3 品牌方端 (Brand)
|
||||
|
||||
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
|
||||
> **侧边栏导航顺序:** 项目看板 → 创建项目 → 终审台 → 代理商管理 → 规则配置 → AI配置 → 系统设置
|
||||
|
||||
#### 4.3.1 Desktop 端页面
|
||||
|
||||
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 数据看板 | Desktop | P0 | xUM9m |
|
||||
| AI服务配置 | Desktop | P0 | 4ppiJ |
|
||||
| 规则配置 | Desktop | P0 | nhHSF |
|
||||
| 审计日志 | Desktop | P1 | jELTK |
|
||||
| 代理商管理 | Desktop | P1 | 2jnnO |
|
||||
| 舆情预警 | Desktop | P2 | NjCe7 |
|
||||
| 系统设置 | Desktop | P2 | 4nVj4 |
|
||||
| 数据看板 | Mobile | P0 | lpVdV |
|
||||
| 舆情预警 | Mobile | P1 | wWAel |
|
||||
| 审批中心 | Mobile | P1 | OueOe |
|
||||
| 消息中心 | Mobile | P2 | 1w9xC |
|
||||
| 我的 | Mobile | P2 | OJBbT |
|
||||
| 项目看板 | P0 | xUM9m | 项目列表与数据概览,默认首页 |
|
||||
| 项目详情数据看板 | P1 | D1O6f | 单项目数据分析 |
|
||||
| 创建项目 | P0 | fP5rY | 新建项目表单 |
|
||||
| 终审台(列表页) | P0 | afJEU | 脚本/视频待终审列表 |
|
||||
| 脚本终审决策台 | P0 | Sw2hw | 简单模式(文件图标+预览按钮) |
|
||||
| 脚本终审(预览模式) | P0 | cp5CE | 展开脚本内容审核 |
|
||||
| 视频终审决策台 | P0 | aePi5 | 视频播放+问题标记+决策 |
|
||||
| 代理商管理 | P1 | 2jnnO | 代理商列表与邀请 |
|
||||
| 邀请代理商弹窗 | P1 | GyUlM | 邀请代理商模态框 |
|
||||
| 规则配置 | P0 | nhHSF | 黑名单/白名单管理 |
|
||||
| AI服务配置 | P0 | 4ppiJ | AI模型与参数配置 |
|
||||
| 系统设置 | P2 | 4nVj4 | 通用设置、安全、数据导出 |
|
||||
|
||||
#### 4.3.2 Mobile 端页面
|
||||
|
||||
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 数据看板 | P0 | lpVdV | 关键指标概览 |
|
||||
| 审批中心 | P1 | OueOe | 终审处理 |
|
||||
| 消息中心 | P2 | 1w9xC | 通知列表 |
|
||||
| 我的 | P2 | OJBbT | 个人设置 |
|
||||
|
||||
#### 4.3.3 页面跳转关系
|
||||
|
||||
```
|
||||
侧边栏导航
|
||||
├── 项目看板 ─────────► 点击项目卡片 → 项目详情数据看板
|
||||
├── 创建项目 ─────────► 填写表单 → 保存 → 项目看板
|
||||
├── 终审台 ───────────► 终审台(列表页)
|
||||
│ ├── 脚本任务 ────► 脚本终审决策台 ─► 预览脚本按钮 → 预览模式
|
||||
│ └── 视频任务 ────► 视频终审决策台
|
||||
├── 代理商管理 ───────► 邀请按钮 → 邀请代理商弹窗
|
||||
├── 规则配置 ─────────► 黑名单/白名单管理
|
||||
├── AI配置 ───────────► AI服务配置页
|
||||
└── 系统设置 ─────────► 系统设置页
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -388,4 +470,14 @@ module.exports = {
|
||||
---
|
||||
|
||||
**文档维护者**: Claude
|
||||
**最后更新**: 2026-02-03
|
||||
**最后更新**: 2026-02-06
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 作者 | 变更说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| V1.0 | 2026-02-03 | Claude | 初稿:设计令牌、组件规范、页面清单 |
|
||||
| V1.1 | 2026-02-05 | Claude | **明确两阶段审核页面**:细化达人端页面清单,按脚本阶段/视频阶段分类;新增脚本品牌方不通过(NeF4L)、视频AI不通过(6EX4Z)页面 |
|
||||
| V1.2 | 2026-02-06 | Claude | **完善品牌方端和代理商端页面清单**:更新侧边栏导航顺序;新增规则配置、脚本终审(预览模式)等页面;补充页面跳转关系图 |
|
||||
|
||||
+679
-193
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>SmartAudit · AI 服务配置(说明版)</title>
|
||||
<title>秒思智能审核平台 · AI 服务配置(说明版)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0c0f;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
*.env
|
||||
|
||||
# Temp files
|
||||
*.log
|
||||
*.tmp
|
||||
/tmp/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
@@ -0,0 +1,55 @@
|
||||
# ===========================
|
||||
# 秒思智能审核平台 - 后端环境变量
|
||||
# ===========================
|
||||
# 复制此文件为 .env 并填入实际值
|
||||
# cp .env.example .env
|
||||
|
||||
# --- 应用 ---
|
||||
APP_NAME=秒思智能审核平台
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
ENVIRONMENT=production
|
||||
|
||||
# --- 数据库 ---
|
||||
POSTGRES_USER=miaosi
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
POSTGRES_DB=miaosi
|
||||
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# --- JWT ---
|
||||
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# --- AI 服务 (中转服务商) ---
|
||||
AI_PROVIDER=oneapi
|
||||
AI_API_KEY=
|
||||
AI_API_BASE_URL=
|
||||
|
||||
# --- 火山引擎 TOS ---
|
||||
TOS_ACCESS_KEY_ID=
|
||||
TOS_SECRET_ACCESS_KEY=
|
||||
TOS_REGION=cn-beijing
|
||||
TOS_BUCKET_NAME=miaosi-files
|
||||
TOS_ENDPOINT=
|
||||
TOS_CDN_DOMAIN=
|
||||
|
||||
# --- 邮件 SMTP ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_NAME=秒思智能审核平台
|
||||
SMTP_USE_SSL=true
|
||||
|
||||
# --- 加密密钥 ---
|
||||
# 用于加密存储 API 密钥等敏感数据
|
||||
# 生成方法: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# --- 文件上传 ---
|
||||
MAX_FILE_SIZE_MB=500
|
||||
@@ -0,0 +1,94 @@
|
||||
# 后端开发备忘
|
||||
|
||||
## 文件预览相关 API
|
||||
|
||||
### 1. 文件上传与存储
|
||||
- 达人上传脚本文件(支持 .docx, .pdf, .xlsx, .txt 等)
|
||||
- 达人上传视频文件(支持 .mp4, .mov, .webm 等)
|
||||
- 文件存储到 OSS/S3,返回访问 URL
|
||||
|
||||
### 2. 文件访问 API
|
||||
```
|
||||
GET /api/files/:fileId
|
||||
返回:{ url: "文件访问URL", fileName, fileSize, fileType, uploadedAt }
|
||||
```
|
||||
|
||||
### 3. 文件类型转换(可选,提升体验)
|
||||
- Word (.docx) → PDF
|
||||
- Excel (.xlsx) → PDF
|
||||
- PPT (.pptx) → PDF
|
||||
- 使用 LibreOffice 或 Pandoc 实现
|
||||
|
||||
### 4. 视频流服务
|
||||
- 支持视频分段加载(Range 请求)
|
||||
- 支持视频缩略图生成
|
||||
|
||||
---
|
||||
|
||||
## 审核相关 API
|
||||
|
||||
### 脚本审核
|
||||
```
|
||||
GET /api/agency/review/scripts # 待审脚本列表
|
||||
GET /api/agency/review/scripts/:id # 脚本详情(含文件URL、AI分析结果)
|
||||
POST /api/agency/review/scripts/:id/approve # 通过
|
||||
POST /api/agency/review/scripts/:id/reject # 驳回
|
||||
POST /api/agency/review/scripts/:id/force-pass # 强制通过
|
||||
```
|
||||
|
||||
### 视频审核
|
||||
```
|
||||
GET /api/agency/review/videos # 待审视频列表
|
||||
GET /api/agency/review/videos/:id # 视频详情(含文件URL、AI分析结果)
|
||||
POST /api/agency/review/videos/:id/approve # 通过
|
||||
POST /api/agency/review/videos/:id/reject # 驳回
|
||||
POST /api/agency/review/videos/:id/force-pass # 强制通过
|
||||
```
|
||||
|
||||
### 品牌方终审
|
||||
```
|
||||
GET /api/brand/review/scripts # 待终审脚本列表
|
||||
GET /api/brand/review/scripts/:id # 脚本详情
|
||||
POST /api/brand/review/scripts/:id/approve # 终审通过
|
||||
POST /api/brand/review/scripts/:id/reject # 终审驳回
|
||||
|
||||
GET /api/brand/review/videos # 待终审视频列表
|
||||
GET /api/brand/review/videos/:id # 视频详情
|
||||
POST /api/brand/review/videos/:id/approve # 终审通过
|
||||
POST /api/brand/review/videos/:id/reject # 终审驳回
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 申诉相关字段
|
||||
|
||||
审核列表和详情需要包含:
|
||||
- `isAppeal: boolean` - 是否为申诉
|
||||
- `appealReason: string` - 申诉理由
|
||||
- `appealCount: number` - 第几次申诉
|
||||
|
||||
---
|
||||
|
||||
## 文件数据结构
|
||||
|
||||
```typescript
|
||||
interface FileInfo {
|
||||
id: string
|
||||
fileName: string
|
||||
fileSize: string // "1.5 MB"
|
||||
fileType: string // "video/mp4", "application/pdf", etc.
|
||||
fileUrl: string // 访问URL
|
||||
uploadedAt: string // ISO 时间
|
||||
// 视频特有
|
||||
duration?: number // 秒
|
||||
thumbnail?: string // 缩略图URL
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 文件 URL 需要支持跨域访问(CORS)
|
||||
2. 视频需要支持 Range 请求实现分段加载
|
||||
3. 敏感文件考虑使用签名 URL(有效期限制)
|
||||
@@ -0,0 +1,58 @@
|
||||
# ===========================
|
||||
# 秒思智能审核平台 - Backend Dockerfile
|
||||
# 多阶段构建,基于 python:3.13-slim
|
||||
# ===========================
|
||||
|
||||
# ---------- Stage 1: 构建依赖 ----------
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 安装编译依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖描述文件
|
||||
COPY pyproject.toml .
|
||||
|
||||
# 安装 Python 依赖到 /build/deps
|
||||
RUN pip install --no-cache-dir --prefix=/build/deps .
|
||||
|
||||
# ---------- Stage 2: 运行时镜像 ----------
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装运行时系统依赖(FFmpeg 用于视频处理,libpq 用于 PostgreSQL)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 阶段复制已安装的 Python 依赖
|
||||
COPY --from=builder /build/deps /usr/local
|
||||
|
||||
# 复制应用代码
|
||||
COPY app/ ./app/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
COPY pyproject.toml .
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
|
||||
&& mkdir -p /tmp/videos \
|
||||
&& chown -R miaosi:miaosi /app /tmp/videos
|
||||
|
||||
USER miaosi
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./scripts/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Alembic 配置文件
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本目录
|
||||
script_location = alembic
|
||||
|
||||
# 版本位置模板
|
||||
# file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path 路径
|
||||
prepend_sys_path = .
|
||||
|
||||
# 时区
|
||||
# timezone =
|
||||
|
||||
# 版本文件格式
|
||||
version_path_separator = os
|
||||
|
||||
# 输出编码
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# 格式化迁移脚本
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -q
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
支持异步数据库迁移
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# 导入配置和模型
|
||||
from app.config import settings
|
||||
from app.models.base import Base
|
||||
# 导入所有模型,确保 autogenerate 能检测到全部表
|
||||
from app.models import * # noqa: F401,F403
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 设置数据库 URL
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# 日志配置
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# MetaData 对象用于 autogenerate
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
不需要数据库连接,只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""执行迁移"""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""异步运行迁移"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
使用异步引擎连接数据库
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""初始表结构
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2024-01-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
platform_enum = postgresql.ENUM(
|
||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||
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',
|
||||
create_type=False,
|
||||
)
|
||||
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# 租户表
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# AI 配置表
|
||||
op.create_table(
|
||||
'ai_configs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('provider', sa.String(50), nullable=False),
|
||||
sa.Column('base_url', sa.String(500), nullable=False),
|
||||
sa.Column('api_key_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('models', postgresql.JSONB(), nullable=False),
|
||||
sa.Column('temperature', sa.Float(), nullable=False, default=0.7),
|
||||
sa.Column('max_tokens', sa.Integer(), nullable=False, default=2000),
|
||||
sa.Column('available_models', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('last_test_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_test_result', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('is_configured', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_ai_configs_tenant_id', 'ai_configs', ['tenant_id'])
|
||||
|
||||
# 审核任务表
|
||||
op.create_table(
|
||||
'review_tasks',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('video_url', sa.String(2048), nullable=False),
|
||||
sa.Column('platform', platform_enum, nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('creator_id', sa.String(64), nullable=False),
|
||||
sa.Column('status', task_status_enum, nullable=False, default='pending'),
|
||||
sa.Column('progress', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('current_step', sa.String(100), nullable=False, default='等待处理'),
|
||||
sa.Column('score', sa.Integer(), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('violations', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('soft_warnings', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('requirements', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('competitors', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_review_tasks_tenant_id', 'review_tasks', ['tenant_id'])
|
||||
op.create_index('ix_review_tasks_brand_id', 'review_tasks', ['brand_id'])
|
||||
op.create_index('ix_review_tasks_creator_id', 'review_tasks', ['creator_id'])
|
||||
op.create_index('ix_review_tasks_status', 'review_tasks', ['status'])
|
||||
|
||||
# 违禁词表
|
||||
op.create_table(
|
||||
'forbidden_words',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('word', sa.String(255), nullable=False),
|
||||
sa.Column('category', sa.String(100), nullable=False),
|
||||
sa.Column('severity', sa.String(50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_forbidden_words_tenant_id', 'forbidden_words', ['tenant_id'])
|
||||
op.create_index('ix_forbidden_words_word', 'forbidden_words', ['word'])
|
||||
op.create_index('ix_forbidden_words_category', 'forbidden_words', ['category'])
|
||||
|
||||
# 白名单表
|
||||
op.create_table(
|
||||
'whitelist_items',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('term', sa.String(255), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_whitelist_items_tenant_id', 'whitelist_items', ['tenant_id'])
|
||||
op.create_index('ix_whitelist_items_brand_id', 'whitelist_items', ['brand_id'])
|
||||
op.create_index('ix_whitelist_items_term', 'whitelist_items', ['term'])
|
||||
|
||||
# 竞品表
|
||||
op.create_table(
|
||||
'competitors',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('logo_url', sa.String(2048), nullable=True),
|
||||
sa.Column('keywords', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_competitors_tenant_id', 'competitors', ['tenant_id'])
|
||||
op.create_index('ix_competitors_brand_id', 'competitors', ['brand_id'])
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除表
|
||||
op.drop_table('competitors')
|
||||
op.drop_table('whitelist_items')
|
||||
op.drop_table('forbidden_words')
|
||||
op.drop_table('review_tasks')
|
||||
op.drop_table('ai_configs')
|
||||
op.drop_table('tenants')
|
||||
|
||||
# 删除枚举类型
|
||||
op.execute('DROP TYPE IF EXISTS task_status_enum')
|
||||
op.execute('DROP TYPE IF EXISTS platform_enum')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add manual task script/video upload fields
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-02-04
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,246 @@
|
||||
"""添加用户、组织、项目、任务表
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-02-09
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '003'
|
||||
down_revision: Union[str, None] = '002'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
user_role_enum = postgresql.ENUM(
|
||||
'brand', 'agency', 'creator',
|
||||
name='user_role_enum',
|
||||
create_type=False,
|
||||
)
|
||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
task_stage_enum = postgresql.ENUM(
|
||||
'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',
|
||||
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',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('email', sa.String(255), unique=True, nullable=True, index=True),
|
||||
sa.Column('phone', sa.String(20), unique=True, nullable=True, index=True),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('avatar', sa.String(2048), nullable=True),
|
||||
sa.Column('role', postgresql.ENUM('brand', 'agency', 'creator', name='user_role_enum', create_type=False), nullable=False, index=True),
|
||||
sa.Column('is_active', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('is_verified', sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('refresh_token', sa.String(512), nullable=True),
|
||||
sa.Column('refresh_token_expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 品牌方表
|
||||
op.create_table(
|
||||
'brands',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('logo', sa.String(2048), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('contact_name', sa.String(100), nullable=True),
|
||||
sa.Column('contact_phone', sa.String(20), nullable=True),
|
||||
sa.Column('contact_email', sa.String(255), nullable=True),
|
||||
sa.Column('final_review_enabled', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 代理商表
|
||||
op.create_table(
|
||||
'agencies',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('logo', sa.String(2048), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('contact_name', sa.String(100), nullable=True),
|
||||
sa.Column('contact_phone', sa.String(20), nullable=True),
|
||||
sa.Column('contact_email', sa.String(255), nullable=True),
|
||||
sa.Column('force_pass_enabled', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 达人表
|
||||
op.create_table(
|
||||
'creators',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('avatar', sa.String(2048), nullable=True),
|
||||
sa.Column('bio', sa.Text(), nullable=True),
|
||||
sa.Column('douyin_account', sa.String(100), nullable=True),
|
||||
sa.Column('xiaohongshu_account', sa.String(100), nullable=True),
|
||||
sa.Column('bilibili_account', sa.String(100), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), default=True, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 品牌方-代理商关联表
|
||||
op.create_table(
|
||||
'brand_agency',
|
||||
sa.Column('brand_id', sa.String(64), sa.ForeignKey('brands.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('agency_id', sa.String(64), sa.ForeignKey('agencies.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
)
|
||||
|
||||
# 代理商-达人关联表
|
||||
op.create_table(
|
||||
'agency_creator',
|
||||
sa.Column('agency_id', sa.String(64), sa.ForeignKey('agencies.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('creator_id', sa.String(64), sa.ForeignKey('creators.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
)
|
||||
|
||||
# 项目表
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('brand_id', sa.String(64), sa.ForeignKey('brands.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deadline', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(20), default='active', nullable=False, index=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 项目-代理商关联表
|
||||
op.create_table(
|
||||
'project_agency',
|
||||
sa.Column('project_id', sa.String(64), sa.ForeignKey('projects.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('agency_id', sa.String(64), sa.ForeignKey('agencies.id', ondelete='CASCADE'), primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
)
|
||||
|
||||
# Brief 表
|
||||
op.create_table(
|
||||
'briefs',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('project_id', sa.String(64), sa.ForeignKey('projects.id', ondelete='CASCADE'), unique=True, nullable=False, index=True),
|
||||
sa.Column('file_url', sa.String(2048), nullable=True),
|
||||
sa.Column('file_name', sa.String(255), nullable=True),
|
||||
sa.Column('selling_points', postgresql.JSON(), nullable=True),
|
||||
sa.Column('blacklist_words', postgresql.JSON(), nullable=True),
|
||||
sa.Column('competitors', postgresql.JSON(), nullable=True),
|
||||
sa.Column('brand_tone', sa.Text(), nullable=True),
|
||||
sa.Column('min_duration', sa.Integer(), nullable=True),
|
||||
sa.Column('max_duration', sa.Integer(), nullable=True),
|
||||
sa.Column('other_requirements', sa.Text(), nullable=True),
|
||||
sa.Column('attachments', postgresql.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# 任务表
|
||||
op.create_table(
|
||||
'tasks',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('project_id', sa.String(64), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('agency_id', sa.String(64), sa.ForeignKey('agencies.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('creator_id', sa.String(64), sa.ForeignKey('creators.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('sequence', sa.Integer(), default=1, nullable=False),
|
||||
sa.Column('stage', postgresql.ENUM(
|
||||
'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', create_type=False
|
||||
), default='script_upload', nullable=False, index=True),
|
||||
|
||||
# 脚本相关
|
||||
sa.Column('script_file_url', sa.String(2048), nullable=True),
|
||||
sa.Column('script_file_name', sa.String(255), nullable=True),
|
||||
sa.Column('script_uploaded_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('script_ai_score', sa.Integer(), nullable=True),
|
||||
sa.Column('script_ai_result', postgresql.JSON(), nullable=True),
|
||||
sa.Column('script_ai_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('script_agency_status', postgresql.ENUM('pending', 'processing', 'passed', 'rejected', 'force_passed', name='task_status_enum', create_type=False), nullable=True),
|
||||
sa.Column('script_agency_comment', sa.Text(), nullable=True),
|
||||
sa.Column('script_agency_reviewer_id', sa.String(64), nullable=True),
|
||||
sa.Column('script_agency_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('script_brand_status', postgresql.ENUM('pending', 'processing', 'passed', 'rejected', 'force_passed', name='task_status_enum', create_type=False), nullable=True),
|
||||
sa.Column('script_brand_comment', sa.Text(), nullable=True),
|
||||
sa.Column('script_brand_reviewer_id', sa.String(64), nullable=True),
|
||||
sa.Column('script_brand_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
|
||||
# 视频相关
|
||||
sa.Column('video_file_url', sa.String(2048), nullable=True),
|
||||
sa.Column('video_file_name', sa.String(255), nullable=True),
|
||||
sa.Column('video_duration', sa.Integer(), nullable=True),
|
||||
sa.Column('video_thumbnail_url', sa.String(2048), nullable=True),
|
||||
sa.Column('video_uploaded_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('video_ai_score', sa.Integer(), nullable=True),
|
||||
sa.Column('video_ai_result', postgresql.JSON(), nullable=True),
|
||||
sa.Column('video_ai_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('video_agency_status', postgresql.ENUM('pending', 'processing', 'passed', 'rejected', 'force_passed', name='task_status_enum', create_type=False), nullable=True),
|
||||
sa.Column('video_agency_comment', sa.Text(), nullable=True),
|
||||
sa.Column('video_agency_reviewer_id', sa.String(64), nullable=True),
|
||||
sa.Column('video_agency_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('video_brand_status', postgresql.ENUM('pending', 'processing', 'passed', 'rejected', 'force_passed', name='task_status_enum', create_type=False), nullable=True),
|
||||
sa.Column('video_brand_comment', sa.Text(), nullable=True),
|
||||
sa.Column('video_brand_reviewer_id', sa.String(64), nullable=True),
|
||||
sa.Column('video_brand_reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
|
||||
# 申诉相关
|
||||
sa.Column('appeal_count', sa.Integer(), default=1, nullable=False),
|
||||
sa.Column('is_appeal', sa.Boolean(), default=False, nullable=False),
|
||||
sa.Column('appeal_reason', sa.Text(), nullable=True),
|
||||
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('tasks')
|
||||
op.drop_table('briefs')
|
||||
op.drop_table('project_agency')
|
||||
op.drop_table('projects')
|
||||
op.drop_table('agency_creator')
|
||||
op.drop_table('brand_agency')
|
||||
op.drop_table('creators')
|
||||
op.drop_table('agencies')
|
||||
op.drop_table('brands')
|
||||
op.drop_table('users')
|
||||
|
||||
# 删除枚举类型
|
||||
op.execute("DROP TYPE IF EXISTS task_stage_enum")
|
||||
op.execute("DROP TYPE IF EXISTS user_role_enum")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""添加审计日志表
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-02-09
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '004'
|
||||
down_revision: Union[str, None] = '003'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'audit_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('action', sa.String(50), nullable=False, index=True),
|
||||
sa.Column('resource_type', sa.String(50), nullable=False, index=True),
|
||||
sa.Column('resource_id', sa.String(64), nullable=True, index=True),
|
||||
sa.Column('user_id', sa.String(64), nullable=True, index=True),
|
||||
sa.Column('user_name', sa.String(255), nullable=True),
|
||||
sa.Column('user_role', sa.String(20), nullable=True),
|
||||
sa.Column('detail', sa.Text(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('audit_logs')
|
||||
@@ -0,0 +1,42 @@
|
||||
"""添加消息表
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-02-09
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '005'
|
||||
down_revision: Union[str, None] = '004'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'messages',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('type', sa.String(50), nullable=False),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('related_task_id', sa.String(64), nullable=True),
|
||||
sa.Column('related_project_id', sa.String(64), nullable=True),
|
||||
sa.Column('sender_name', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_messages_user_id', 'messages', ['user_id'])
|
||||
op.create_index('idx_messages_user_read', 'messages', ['user_id', 'is_read'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_messages_user_read', table_name='messages')
|
||||
op.drop_index('idx_messages_user_id', table_name='messages')
|
||||
op.drop_table('messages')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""添加平台规则表
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-02-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '006'
|
||||
down_revision: Union[str, None] = '005'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'platform_rules',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False, index=True),
|
||||
sa.Column('platform', sa.String(50), nullable=False, index=True),
|
||||
sa.Column('document_url', sa.String(2048), nullable=False),
|
||||
sa.Column('document_name', sa.String(512), nullable=False),
|
||||
sa.Column('parsed_rules', sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, default='draft', index=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('platform_rules')
|
||||
@@ -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')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add min_selling_points to briefs
|
||||
|
||||
Revision ID: 261778c01ef8
|
||||
Revises: 008
|
||||
Create Date: 2026-02-11 18:16:59.557746
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '261778c01ef8'
|
||||
down_revision: Union[str, None] = '008'
|
||||
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('min_selling_points', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('briefs', 'min_selling_points')
|
||||
@@ -0,0 +1,2 @@
|
||||
"""秒思智能审核平台后端服务"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由模块"""
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
AI 服务配置 API
|
||||
品牌方管理 AI 提供商配置、模型选择、连通性测试
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.ai_config import (
|
||||
AIProvider,
|
||||
AIConfigUpdate,
|
||||
AIConfigResponse,
|
||||
AIModelsConfig,
|
||||
AIParametersConfig,
|
||||
GetModelsRequest,
|
||||
TestConnectionRequest,
|
||||
ModelsListResponse,
|
||||
ConnectionTestResponse,
|
||||
ModelTestResult,
|
||||
ModelInfo,
|
||||
ModelCapability,
|
||||
mask_api_key,
|
||||
)
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
router = APIRouter(prefix="/ai-config", tags=["ai-config"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.get("", response_model=AIConfigResponse)
|
||||
async def get_ai_config(
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
获取当前 AI 配置
|
||||
|
||||
- 未配置返回 404
|
||||
- 已配置返回配置信息(API Key 脱敏)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == x_tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="AI 服务未配置,请先完成配置",
|
||||
)
|
||||
|
||||
# 解密 API Key 用于脱敏显示
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=config.is_configured,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=AIConfigResponse)
|
||||
async def update_ai_config(
|
||||
request: AIConfigUpdate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
更新 AI 配置
|
||||
|
||||
- 保存提供商、连接信息、模型配置
|
||||
- API Key 加密存储
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 加密 API Key
|
||||
api_key_encrypted = encrypt_api_key(request.api_key)
|
||||
|
||||
# 创建或更新配置
|
||||
config = await AIServiceFactory.create_or_update_config(
|
||||
tenant_id=x_tenant_id,
|
||||
provider=request.provider.value,
|
||||
base_url=request.base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=request.models.model_dump(),
|
||||
temperature=request.parameters.temperature,
|
||||
max_tokens=request.parameters.max_tokens,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(request.api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=True,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelsListResponse)
|
||||
async def get_available_models(
|
||||
request: GetModelsRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ModelsListResponse:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
- 调用提供商 API 获取模型列表
|
||||
- 按能力分类(text/vision/audio)
|
||||
"""
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
models_dict = await client.list_models()
|
||||
await client.close()
|
||||
|
||||
# 转换为 ModelInfo 对象
|
||||
models = {
|
||||
k: [ModelInfo(**m) for m in v]
|
||||
for k, v in models_dict.items()
|
||||
}
|
||||
|
||||
# 更新配置中的可用模型缓存
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.available_models = models_dict
|
||||
await db.flush()
|
||||
|
||||
return ModelsListResponse(
|
||||
success=True,
|
||||
models=models,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"获取模型列表失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=ConnectionTestResponse)
|
||||
async def test_connection(
|
||||
request: TestConnectionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConnectionTestResponse:
|
||||
"""
|
||||
测试 AI 服务连接
|
||||
|
||||
- 并行测试三个模型
|
||||
- 返回每个模型的测试结果
|
||||
"""
|
||||
client = None
|
||||
models = request.models.model_dump()
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
# 定义模型能力映射
|
||||
capability_map = {
|
||||
"text": ModelCapability.TEXT,
|
||||
"vision": ModelCapability.VISION,
|
||||
"audio": ModelCapability.AUDIO,
|
||||
}
|
||||
|
||||
async def test_single(model_type: str, model_id: str) -> tuple[str, ModelTestResult]:
|
||||
capability = capability_map.get(model_type, ModelCapability.TEXT)
|
||||
result = await client.test_connection(model_id, capability)
|
||||
return model_type, ModelTestResult(
|
||||
success=result.success,
|
||||
latency_ms=result.latency_ms,
|
||||
error=result.error,
|
||||
model=model_id,
|
||||
)
|
||||
|
||||
# 并行测试所有模型
|
||||
tasks = [
|
||||
test_single(model_type, model_id)
|
||||
for model_type, model_id in models.items()
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks)
|
||||
results = {model_type: result for model_type, result in results_list}
|
||||
|
||||
# 计算测试结果
|
||||
all_success = all(r.success for r in results.values())
|
||||
failed_count = sum(1 for r in results.values() if not r.success)
|
||||
|
||||
if all_success:
|
||||
message = "所有模型连接成功"
|
||||
else:
|
||||
message = f"{failed_count} 个模型连接失败,请检查模型名称或 API 权限"
|
||||
|
||||
response = ConnectionTestResponse(
|
||||
success=all_success,
|
||||
results=results,
|
||||
message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 确保接口返回 200,并返回失败详情
|
||||
results = {
|
||||
model_type: ModelTestResult(
|
||||
success=False,
|
||||
latency_ms=0,
|
||||
error=str(exc),
|
||||
model=model_id,
|
||||
)
|
||||
for model_type, model_id in models.items()
|
||||
}
|
||||
response = ConnectionTestResponse(
|
||||
success=False,
|
||||
results=results,
|
||||
message=f"连接测试失败: {str(exc)}",
|
||||
)
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 保存测试结果到数据库
|
||||
db_result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = db_result.scalar_one_or_none()
|
||||
if config:
|
||||
config.last_test_at = datetime.now(timezone.utc)
|
||||
config.last_test_result = {
|
||||
k: v.model_dump() for k, v in response.results.items()
|
||||
}
|
||||
await db.flush()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== 供其他模块调用 ====================
|
||||
|
||||
async def get_ai_config_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[dict]:
|
||||
"""获取租户的 AI 配置(供审核服务调用)"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tenant_id": config.tenant_id,
|
||||
"provider": config.provider,
|
||||
"base_url": config.base_url,
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"models": config.models,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
认证 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
ResetPasswordRequest,
|
||||
SendEmailCodeRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
get_user_by_email,
|
||||
get_user_by_phone,
|
||||
get_user_by_id,
|
||||
create_user,
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
update_refresh_token,
|
||||
decode_token,
|
||||
get_user_organization_info,
|
||||
hash_password,
|
||||
)
|
||||
from app.services.verification import generate_code, verify_code
|
||||
from app.services.email import send_verification_email
|
||||
from app.services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/send-code")
|
||||
async def send_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
发送邮箱验证码
|
||||
|
||||
- purpose=register: 注册用,邮箱不能已被注册
|
||||
- purpose=login: 登录用,邮箱必须已注册
|
||||
- purpose=reset_password: 重置密码用,邮箱必须已注册
|
||||
- 60秒内不可重复发送
|
||||
"""
|
||||
email = request.email
|
||||
purpose = request.purpose
|
||||
|
||||
# 根据用途检查邮箱状态
|
||||
existing = await get_user_by_email(db, email)
|
||||
|
||||
if purpose == "register":
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
elif purpose in ("login", "reset_password"):
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱未注册",
|
||||
)
|
||||
|
||||
# 生成验证码
|
||||
code, error = generate_code(email, purpose)
|
||||
if error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
sent = send_verification_email(email, code, purpose)
|
||||
if not sent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="验证码发送失败,请稍后重试",
|
||||
)
|
||||
|
||||
return {"message": "验证码已发送", "expires_in": 300}
|
||||
|
||||
|
||||
@router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用户注册
|
||||
|
||||
- 需要先调用 /auth/send-code 获取邮箱验证码
|
||||
- 验证码正确后完成注册
|
||||
- 注册后自动登录,返回 Token
|
||||
"""
|
||||
# 验证邮箱验证码
|
||||
if not verify_code(request.email, request.email_code, "register"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
existing = await get_user_by_email(db, request.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
|
||||
# 检查手机号是否已存在
|
||||
if request.phone:
|
||||
existing = await get_user_by_phone(db, request.phone)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已被注册",
|
||||
)
|
||||
|
||||
# 创建用户(邮箱已验证)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
name=request.name,
|
||||
role=request.role,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token, refresh_expires_at = create_refresh_token(user.id)
|
||||
|
||||
# 保存 refresh token
|
||||
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
|
||||
|
||||
# 审计日志
|
||||
await log_action(
|
||||
db, "register", "user", user.id, user.id, user.name, user.role.value,
|
||||
ip_address=req.client.host if req.client else None,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 获取组织信息
|
||||
org_info = await get_user_organization_info(db, user)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
is_verified=user.is_verified,
|
||||
**org_info,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- 支持邮箱+密码登录
|
||||
- 支持邮箱+验证码登录(需先调用 /auth/send-code)
|
||||
"""
|
||||
# 验证请求参数
|
||||
if not request.email and not request.phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供邮箱或手机号",
|
||||
)
|
||||
|
||||
if not request.password and not request.email_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供密码或验证码",
|
||||
)
|
||||
|
||||
user = None
|
||||
|
||||
# 验证码登录
|
||||
if request.email_code and request.email:
|
||||
if not verify_code(request.email, request.email_code, "login"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
user = await get_user_by_email(db, request.email)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
else:
|
||||
# 密码登录
|
||||
user = await authenticate_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="邮箱/手机号或密码错误",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token, refresh_expires_at = create_refresh_token(user.id)
|
||||
|
||||
# 保存 refresh token
|
||||
await update_refresh_token(db, user, refresh_token, refresh_expires_at)
|
||||
|
||||
# 审计日志
|
||||
await log_action(
|
||||
db, "login", "user", user.id, user.id, user.name, user.role.value,
|
||||
ip_address=req.client.host if req.client else None,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 获取组织信息
|
||||
org_info = await get_user_organization_info(db, user)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
is_verified=user.is_verified,
|
||||
**org_info,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||
async def refresh_token(
|
||||
request: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
- 使用 refreshToken 获取新的 accessToken
|
||||
- refreshToken 有效期 7 天
|
||||
"""
|
||||
# 解码 refresh token
|
||||
payload = decode_token(request.refresh_token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 refresh token",
|
||||
)
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 token 类型",
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 token",
|
||||
)
|
||||
|
||||
# 获取用户
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
# 验证 refresh token 是否匹配
|
||||
if user.refresh_token != request.refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="refresh token 已失效",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
# 生成新的 access token
|
||||
access_token = create_access_token(user.id)
|
||||
|
||||
return RefreshTokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(
|
||||
request: ResetPasswordRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
重置密码
|
||||
|
||||
- 需要先调用 /auth/send-code (purpose=reset_password) 获取验证码
|
||||
- 验证码正确后设置新密码
|
||||
"""
|
||||
# 验证验证码
|
||||
if not verify_code(request.email, request.email_code, "reset_password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# 查找用户
|
||||
user = await get_user_by_email(db, request.email)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱未注册",
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
user.password_hash = hash_password(request.new_password)
|
||||
|
||||
# 审计日志
|
||||
await log_action(
|
||||
db, "reset_password", "user", user.id, user.id,
|
||||
user.name, user.role.value,
|
||||
ip_address=req.client.host if req.client else None,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "密码已重置,请使用新密码登录"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
req: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
退出登录
|
||||
|
||||
- 清除 refresh token,使其失效
|
||||
"""
|
||||
current_user.refresh_token = None
|
||||
current_user.refresh_token_expires_at = None
|
||||
|
||||
# 审计日志
|
||||
await log_action(
|
||||
db, "logout", "user", current_user.id, current_user.id,
|
||||
current_user.name, current_user.role.value,
|
||||
ip_address=req.client.host if req.client else None,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "已退出登录"}
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Brief API
|
||||
项目 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
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.project import Project
|
||||
from app.models.brief import Brief
|
||||
from app.models.organization import Brand, Agency
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.brief import (
|
||||
BriefCreateRequest,
|
||||
BriefUpdateRequest,
|
||||
AgencyBriefUpdateRequest,
|
||||
BriefResponse,
|
||||
)
|
||||
from app.services.auth import generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
|
||||
|
||||
|
||||
async def _get_project_with_permission(
|
||||
project_id: str,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
require_write: bool = False,
|
||||
) -> Project:
|
||||
"""获取项目并检查权限"""
|
||||
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.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="无权访问此项目")
|
||||
elif current_user.role == UserRole.AGENCY:
|
||||
if require_write:
|
||||
raise HTTPException(status_code=403, detail="代理商无权修改 Brief")
|
||||
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.CREATOR:
|
||||
# 达人可以查看 Brief(只读)
|
||||
if require_write:
|
||||
raise HTTPException(status_code=403, detail="达人无权修改 Brief")
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def _brief_to_response(brief: Brief) -> BriefResponse:
|
||||
"""转换 Brief 为响应"""
|
||||
return BriefResponse(
|
||||
id=brief.id,
|
||||
project_id=brief.project_id,
|
||||
project_name=brief.project.name if brief.project else None,
|
||||
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,
|
||||
min_duration=brief.min_duration,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=BriefResponse)
|
||||
async def get_brief(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取项目 Brief"""
|
||||
await _get_project_with_permission(project_id, current_user, db)
|
||||
|
||||
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 不存在")
|
||||
|
||||
return _brief_to_response(brief)
|
||||
|
||||
|
||||
@router.post("", response_model=BriefResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_brief(
|
||||
project_id: str,
|
||||
request: BriefCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建项目 Brief(品牌方操作)"""
|
||||
await _get_project_with_permission(project_id, current_user, db, require_write=True)
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await db.execute(
|
||||
select(Brief).where(Brief.project_id == project_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该项目已有 Brief,请使用更新接口")
|
||||
|
||||
brief = Brief(
|
||||
id=generate_id("BF"),
|
||||
project_id=project_id,
|
||||
file_url=request.file_url,
|
||||
file_name=request.file_name,
|
||||
selling_points=request.selling_points,
|
||||
blacklist_words=request.blacklist_words,
|
||||
competitors=request.competitors,
|
||||
brand_tone=request.brand_tone,
|
||||
min_duration=request.min_duration,
|
||||
max_duration=request.max_duration,
|
||||
other_requirements=request.other_requirements,
|
||||
attachments=request.attachments,
|
||||
agency_attachments=request.agency_attachments,
|
||||
)
|
||||
db.add(brief)
|
||||
await db.flush()
|
||||
|
||||
# 重新加载
|
||||
result = await db.execute(
|
||||
select(Brief)
|
||||
.options(selectinload(Brief.project))
|
||||
.where(Brief.id == brief.id)
|
||||
)
|
||||
brief = result.scalar_one()
|
||||
|
||||
return _brief_to_response(brief)
|
||||
|
||||
|
||||
@router.put("", response_model=BriefResponse)
|
||||
async def update_brief(
|
||||
project_id: str,
|
||||
request: BriefUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新项目 Brief(品牌方操作)"""
|
||||
await _get_project_with_permission(project_id, current_user, db, require_write=True)
|
||||
|
||||
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 不存在")
|
||||
|
||||
# 更新字段
|
||||
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)
|
||||
|
||||
|
||||
@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、selling_points、blacklist_words。
|
||||
不能修改品牌方设置的核心 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 不存在")
|
||||
|
||||
# 更新代理商可编辑的字段
|
||||
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)
|
||||
|
||||
|
||||
# ==================== 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)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
工作台统计 API
|
||||
各角色仪表盘所需数据
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.task import Task, TaskStage
|
||||
from app.models.project import Project
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["工作台"])
|
||||
|
||||
|
||||
# ===== 响应模型 =====
|
||||
|
||||
class ReviewCount(BaseModel):
|
||||
"""审核数量"""
|
||||
script: int = 0
|
||||
video: int = 0
|
||||
|
||||
|
||||
class CreatorDashboard(BaseModel):
|
||||
"""达人工作台数据"""
|
||||
total_tasks: int = 0
|
||||
pending_script: int = 0 # 待上传脚本
|
||||
pending_video: int = 0 # 待上传视频
|
||||
in_review: int = 0 # 审核中
|
||||
completed: int = 0 # 已完成
|
||||
rejected: int = 0 # 被驳回
|
||||
|
||||
|
||||
class AgencyDashboard(BaseModel):
|
||||
"""代理商工作台数据"""
|
||||
pending_review: ReviewCount # 待审核
|
||||
pending_appeal: int = 0 # 待处理申诉
|
||||
today_passed: ReviewCount # 今日通过
|
||||
in_progress: ReviewCount # 进行中
|
||||
total_creators: int = 0 # 达人总数
|
||||
total_tasks: int = 0 # 任务总数
|
||||
|
||||
|
||||
class BrandDashboard(BaseModel):
|
||||
"""品牌方工作台数据"""
|
||||
total_projects: int = 0 # 项目总数
|
||||
active_projects: int = 0 # 进行中项目
|
||||
pending_review: ReviewCount # 待终审
|
||||
total_agencies: int = 0 # 代理商总数
|
||||
total_tasks: int = 0 # 任务总数
|
||||
completed_tasks: int = 0 # 已完成任务
|
||||
|
||||
|
||||
# ===== API =====
|
||||
|
||||
@router.get("/creator", response_model=CreatorDashboard)
|
||||
async def get_creator_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""达人工作台统计"""
|
||||
if current_user.role != UserRole.CREATOR:
|
||||
raise HTTPException(status_code=403, detail="仅达人可访问")
|
||||
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.user_id == current_user.id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
if not creator:
|
||||
raise HTTPException(status_code=404, detail="达人信息不存在")
|
||||
|
||||
creator_id = creator.id
|
||||
|
||||
# 各阶段任务数
|
||||
stage_counts = {}
|
||||
for stage in TaskStage:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.creator_id == creator_id, Task.stage == stage)
|
||||
)
|
||||
)
|
||||
stage_counts[stage] = count_result.scalar() or 0
|
||||
|
||||
total_result = await db.execute(
|
||||
select(func.count(Task.id)).where(Task.creator_id == creator_id)
|
||||
)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
in_review = (
|
||||
stage_counts.get(TaskStage.SCRIPT_AI_REVIEW, 0) +
|
||||
stage_counts.get(TaskStage.SCRIPT_AGENCY_REVIEW, 0) +
|
||||
stage_counts.get(TaskStage.SCRIPT_BRAND_REVIEW, 0) +
|
||||
stage_counts.get(TaskStage.VIDEO_AI_REVIEW, 0) +
|
||||
stage_counts.get(TaskStage.VIDEO_AGENCY_REVIEW, 0) +
|
||||
stage_counts.get(TaskStage.VIDEO_BRAND_REVIEW, 0)
|
||||
)
|
||||
|
||||
return CreatorDashboard(
|
||||
total_tasks=total,
|
||||
pending_script=stage_counts.get(TaskStage.SCRIPT_UPLOAD, 0),
|
||||
pending_video=stage_counts.get(TaskStage.VIDEO_UPLOAD, 0),
|
||||
in_review=in_review,
|
||||
completed=stage_counts.get(TaskStage.COMPLETED, 0),
|
||||
rejected=stage_counts.get(TaskStage.REJECTED, 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agency", response_model=AgencyDashboard)
|
||||
async def get_agency_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""代理商工作台统计"""
|
||||
if current_user.role != UserRole.AGENCY:
|
||||
raise HTTPException(status_code=403, detail="仅代理商可访问")
|
||||
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == current_user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if not agency:
|
||||
raise HTTPException(status_code=404, detail="代理商信息不存在")
|
||||
|
||||
agency_id = agency.id
|
||||
|
||||
# 待审核脚本
|
||||
script_review_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.agency_id == agency_id, Task.stage == TaskStage.SCRIPT_AGENCY_REVIEW)
|
||||
)
|
||||
)
|
||||
pending_script = script_review_result.scalar() or 0
|
||||
|
||||
# 待审核视频
|
||||
video_review_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.agency_id == agency_id, Task.stage == TaskStage.VIDEO_AGENCY_REVIEW)
|
||||
)
|
||||
)
|
||||
pending_video = video_review_result.scalar() or 0
|
||||
|
||||
# 待处理申诉
|
||||
appeal_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.agency_id == agency_id, Task.is_appeal == True)
|
||||
)
|
||||
)
|
||||
pending_appeal = appeal_result.scalar() or 0
|
||||
|
||||
# 进行中的脚本(AI审核+代理商审核+品牌方审核)
|
||||
script_stages = [
|
||||
TaskStage.SCRIPT_AI_REVIEW, TaskStage.SCRIPT_AGENCY_REVIEW, TaskStage.SCRIPT_BRAND_REVIEW,
|
||||
]
|
||||
script_progress_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.agency_id == agency_id, Task.stage.in_(script_stages))
|
||||
)
|
||||
)
|
||||
in_progress_script = script_progress_result.scalar() or 0
|
||||
|
||||
# 进行中的视频
|
||||
video_stages = [
|
||||
TaskStage.VIDEO_AI_REVIEW, TaskStage.VIDEO_AGENCY_REVIEW, TaskStage.VIDEO_BRAND_REVIEW,
|
||||
]
|
||||
video_progress_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.agency_id == agency_id, Task.stage.in_(video_stages))
|
||||
)
|
||||
)
|
||||
in_progress_video = video_progress_result.scalar() or 0
|
||||
|
||||
# 达人总数
|
||||
from sqlalchemy.orm import selectinload
|
||||
agency_loaded = await db.execute(
|
||||
select(Agency).options(selectinload(Agency.creators)).where(Agency.id == agency_id)
|
||||
)
|
||||
agency_with_creators = agency_loaded.scalar_one()
|
||||
total_creators = len(agency_with_creators.creators)
|
||||
|
||||
# 任务总数
|
||||
total_result = await db.execute(
|
||||
select(func.count(Task.id)).where(Task.agency_id == agency_id)
|
||||
)
|
||||
total_tasks = total_result.scalar() or 0
|
||||
|
||||
return AgencyDashboard(
|
||||
pending_review=ReviewCount(script=pending_script, video=pending_video),
|
||||
pending_appeal=pending_appeal,
|
||||
today_passed=ReviewCount(script=0, video=0), # TODO: 按日期过滤
|
||||
in_progress=ReviewCount(script=in_progress_script, video=in_progress_video),
|
||||
total_creators=total_creators,
|
||||
total_tasks=total_tasks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/brand", response_model=BrandDashboard)
|
||||
async def get_brand_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""品牌方工作台统计"""
|
||||
if current_user.role != UserRole.BRAND:
|
||||
raise HTTPException(status_code=403, detail="仅品牌方可访问")
|
||||
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == current_user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(status_code=404, detail="品牌方信息不存在")
|
||||
|
||||
brand_id = brand.id
|
||||
|
||||
# 项目统计
|
||||
total_projects_result = await db.execute(
|
||||
select(func.count(Project.id)).where(Project.brand_id == brand_id)
|
||||
)
|
||||
total_projects = total_projects_result.scalar() or 0
|
||||
|
||||
active_projects_result = await db.execute(
|
||||
select(func.count(Project.id)).where(
|
||||
and_(Project.brand_id == brand_id, Project.status == "active")
|
||||
)
|
||||
)
|
||||
active_projects = active_projects_result.scalar() or 0
|
||||
|
||||
# 获取项目 ID 列表
|
||||
project_ids_result = await db.execute(
|
||||
select(Project.id).where(Project.brand_id == brand_id)
|
||||
)
|
||||
project_ids = [row[0] for row in project_ids_result.all()]
|
||||
|
||||
pending_script = 0
|
||||
pending_video = 0
|
||||
total_tasks = 0
|
||||
completed_tasks = 0
|
||||
|
||||
if project_ids:
|
||||
# 待终审脚本
|
||||
script_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.SCRIPT_BRAND_REVIEW)
|
||||
)
|
||||
)
|
||||
pending_script = script_result.scalar() or 0
|
||||
|
||||
# 待终审视频
|
||||
video_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.VIDEO_BRAND_REVIEW)
|
||||
)
|
||||
)
|
||||
pending_video = video_result.scalar() or 0
|
||||
|
||||
# 任务总数
|
||||
total_tasks_result = await db.execute(
|
||||
select(func.count(Task.id)).where(Task.project_id.in_(project_ids))
|
||||
)
|
||||
total_tasks = total_tasks_result.scalar() or 0
|
||||
|
||||
# 已完成
|
||||
completed_result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.COMPLETED)
|
||||
)
|
||||
)
|
||||
completed_tasks = completed_result.scalar() or 0
|
||||
|
||||
# 代理商总数
|
||||
from sqlalchemy.orm import selectinload
|
||||
brand_loaded = await db.execute(
|
||||
select(Brand).options(selectinload(Brand.agencies)).where(Brand.id == brand_id)
|
||||
)
|
||||
brand_with_agencies = brand_loaded.scalar_one()
|
||||
total_agencies = len(brand_with_agencies.agencies)
|
||||
|
||||
return BrandDashboard(
|
||||
total_projects=total_projects,
|
||||
active_projects=active_projects,
|
||||
pending_review=ReviewCount(script=pending_script, video=pending_video),
|
||||
total_agencies=total_agencies,
|
||||
total_tasks=total_tasks,
|
||||
completed_tasks=completed_tasks,
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
API 依赖项
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, Header, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.services.auth import decode_token
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""获取当前登录用户"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未提供认证信息",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 Token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 Token 类型",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 Token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="账号已被禁用",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Optional[User]:
|
||||
"""获取可选的当前用户(未登录时返回 None)"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_brand(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Brand:
|
||||
"""获取当前品牌方(仅品牌方角色可用)"""
|
||||
if current_user.role != UserRole.BRAND:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅品牌方可执行此操作",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == current_user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌方信息不存在",
|
||||
)
|
||||
|
||||
return brand
|
||||
|
||||
|
||||
async def get_current_agency(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Agency:
|
||||
"""获取当前代理商(仅代理商角色可用)"""
|
||||
if current_user.role != UserRole.AGENCY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅代理商可执行此操作",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == current_user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
|
||||
if not agency:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="代理商信息不存在",
|
||||
)
|
||||
|
||||
return agency
|
||||
|
||||
|
||||
async def get_current_creator(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Creator:
|
||||
"""获取当前达人(仅达人角色可用)"""
|
||||
if current_user.role != UserRole.CREATOR:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅达人可执行此操作",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.user_id == current_user.id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
|
||||
if not creator:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="达人信息不存在",
|
||||
)
|
||||
|
||||
return creator
|
||||
|
||||
|
||||
def require_roles(*roles: UserRole):
|
||||
"""角色权限检查装饰器"""
|
||||
async def checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role not in roles:
|
||||
role_names = [r.value for r in roles]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"需要以下角色之一: {', '.join(role_names)}",
|
||||
)
|
||||
return current_user
|
||||
return checker
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
数据导出 API
|
||||
支持导出任务数据和审计日志为 CSV 格式
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.task import Task, TaskStage
|
||||
from app.models.project import Project
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["数据导出"])
|
||||
|
||||
|
||||
def _iter_csv(header: list[str], rows: list[list[str]]):
|
||||
"""
|
||||
生成 CSV 流式响应的迭代器。
|
||||
首行输出 UTF-8 BOM + 表头,之后逐行输出数据。
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
|
||||
# 写入 BOM + 表头
|
||||
writer.writerow(header)
|
||||
yield "\ufeff" + buf.getvalue()
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
|
||||
# 逐行写入数据
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
yield buf.getvalue()
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
|
||||
|
||||
def _format_datetime(dt: Optional[datetime]) -> str:
|
||||
"""格式化日期时间为字符串"""
|
||||
if dt is None:
|
||||
return ""
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _format_stage(stage: Optional[TaskStage]) -> str:
|
||||
"""将任务阶段转换为中文标签"""
|
||||
if stage is None:
|
||||
return ""
|
||||
stage_labels = {
|
||||
TaskStage.SCRIPT_UPLOAD: "待上传脚本",
|
||||
TaskStage.SCRIPT_AI_REVIEW: "脚本AI审核中",
|
||||
TaskStage.SCRIPT_AGENCY_REVIEW: "脚本代理商审核中",
|
||||
TaskStage.SCRIPT_BRAND_REVIEW: "脚本品牌方终审中",
|
||||
TaskStage.VIDEO_UPLOAD: "待上传视频",
|
||||
TaskStage.VIDEO_AI_REVIEW: "视频AI审核中",
|
||||
TaskStage.VIDEO_AGENCY_REVIEW: "视频代理商审核中",
|
||||
TaskStage.VIDEO_BRAND_REVIEW: "视频品牌方终审中",
|
||||
TaskStage.COMPLETED: "已完成",
|
||||
TaskStage.REJECTED: "已驳回",
|
||||
}
|
||||
return stage_labels.get(stage, stage.value)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def export_tasks(
|
||||
project_id: Optional[str] = Query(None, description="按项目ID筛选"),
|
||||
start_date: Optional[date] = Query(None, description="开始日期 (YYYY-MM-DD)"),
|
||||
end_date: Optional[date] = Query(None, description="结束日期 (YYYY-MM-DD)"),
|
||||
current_user: User = Depends(require_roles(UserRole.BRAND, UserRole.AGENCY)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
导出任务数据为 CSV
|
||||
|
||||
- 仅限品牌方和代理商角色
|
||||
- 支持按项目ID、时间范围筛选
|
||||
- 返回 CSV 文件流
|
||||
"""
|
||||
# 构建查询,预加载关联数据
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
)
|
||||
|
||||
# 根据角色限定数据范围
|
||||
if current_user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == current_user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌方信息不存在",
|
||||
)
|
||||
# 品牌方只能导出自己项目下的任务
|
||||
query = query.join(Task.project).where(Project.brand_id == brand.id)
|
||||
|
||||
elif current_user.role == UserRole.AGENCY:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == current_user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if not agency:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="代理商信息不存在",
|
||||
)
|
||||
# 代理商只能导出自己负责的任务
|
||||
query = query.where(Task.agency_id == agency.id)
|
||||
|
||||
# 可选筛选条件
|
||||
if project_id:
|
||||
query = query.where(Task.project_id == project_id)
|
||||
|
||||
if start_date:
|
||||
query = query.where(Task.created_at >= datetime.combine(start_date, datetime.min.time()))
|
||||
|
||||
if end_date:
|
||||
query = query.where(Task.created_at <= datetime.combine(end_date, datetime.max.time()))
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
# 构建 CSV 数据
|
||||
header = ["任务ID", "任务名称", "项目名称", "阶段", "达人名称", "代理商名称", "创建时间", "更新时间"]
|
||||
rows = []
|
||||
for task in tasks:
|
||||
rows.append([
|
||||
task.id,
|
||||
task.name,
|
||||
task.project.name if task.project else "",
|
||||
_format_stage(task.stage),
|
||||
task.creator.name if task.creator else "",
|
||||
task.agency.name if task.agency else "",
|
||||
_format_datetime(task.created_at),
|
||||
_format_datetime(task.updated_at),
|
||||
])
|
||||
|
||||
# 生成文件名
|
||||
filename = f"tasks_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
_iter_csv(header, rows),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
async def export_audit_logs(
|
||||
start_date: Optional[date] = Query(None, description="开始日期 (YYYY-MM-DD)"),
|
||||
end_date: Optional[date] = Query(None, description="结束日期 (YYYY-MM-DD)"),
|
||||
action: Optional[str] = Query(None, description="操作类型筛选 (如 login, create_project, review_task)"),
|
||||
current_user: User = Depends(require_roles(UserRole.BRAND)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
导出审计日志为 CSV
|
||||
|
||||
- 仅限品牌方角色
|
||||
- 支持按时间范围、操作类型筛选
|
||||
- 返回 CSV 文件流
|
||||
"""
|
||||
# 验证品牌方身份
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == current_user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌方信息不存在",
|
||||
)
|
||||
|
||||
# 构建查询
|
||||
query = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
|
||||
if start_date:
|
||||
query = query.where(AuditLog.created_at >= datetime.combine(start_date, datetime.min.time()))
|
||||
|
||||
if end_date:
|
||||
query = query.where(AuditLog.created_at <= datetime.combine(end_date, datetime.max.time()))
|
||||
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# 构建 CSV 数据
|
||||
header = ["日志ID", "操作类型", "资源类型", "资源ID", "操作用户", "用户角色", "详情", "IP地址", "操作时间"]
|
||||
rows = []
|
||||
for log in logs:
|
||||
rows.append([
|
||||
str(log.id),
|
||||
log.action or "",
|
||||
log.resource_type or "",
|
||||
log.resource_id or "",
|
||||
log.user_name or "",
|
||||
log.user_role or "",
|
||||
log.detail or "",
|
||||
log.ip_address or "",
|
||||
_format_datetime(log.created_at),
|
||||
])
|
||||
|
||||
# 生成文件名
|
||||
filename = f"audit_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
_iter_csv(header, rows),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""健康检查 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
from app.services.health import HealthChecker, get_health_checker
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
健康检查端点
|
||||
|
||||
Returns:
|
||||
dict: 包含服务状态信息
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness_check(
|
||||
health_checker: HealthChecker = Depends(get_health_checker),
|
||||
):
|
||||
"""
|
||||
就绪检查端点(用于 K8s)
|
||||
检查数据库、Redis 等依赖服务是否就绪
|
||||
|
||||
Returns:
|
||||
dict: 服务就绪状态和依赖检查结果
|
||||
"""
|
||||
checks = await health_checker.check_all()
|
||||
all_ready = all(checks.values())
|
||||
|
||||
return {
|
||||
"ready": all_ready,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def liveness_check():
|
||||
"""
|
||||
存活检查端点(用于 K8s)
|
||||
只检查服务进程是否存活,不检查依赖
|
||||
|
||||
Returns:
|
||||
dict: 服务存活状态
|
||||
"""
|
||||
return {"alive": True}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
消息/通知 API
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.message import MessageResponse, MessageListResponse, UnreadCountResponse
|
||||
from app.services.message_service import (
|
||||
list_messages,
|
||||
get_unread_count,
|
||||
mark_as_read,
|
||||
mark_all_as_read,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["消息"])
|
||||
|
||||
|
||||
@router.get("", response_model=MessageListResponse)
|
||||
async def get_messages(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
is_read: Optional[bool] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取消息列表"""
|
||||
messages, total = await list_messages(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
is_read=is_read,
|
||||
type=type,
|
||||
)
|
||||
|
||||
return MessageListResponse(
|
||||
items=[
|
||||
MessageResponse(
|
||||
id=m.id,
|
||||
type=m.type,
|
||||
title=m.title,
|
||||
content=m.content,
|
||||
is_read=m.is_read,
|
||||
related_task_id=m.related_task_id,
|
||||
related_project_id=m.related_project_id,
|
||||
sender_name=m.sender_name,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in messages
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def get_message_unread_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取未读消息数"""
|
||||
count = await get_unread_count(db, current_user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.put("/{message_id}/read")
|
||||
async def mark_message_as_read(
|
||||
message_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记消息已读"""
|
||||
success = await mark_as_read(db, message_id, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="消息不存在",
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "已标记为已读"}
|
||||
|
||||
|
||||
@router.put("/read-all")
|
||||
async def mark_all_messages_as_read(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记所有消息已读"""
|
||||
count = await mark_all_as_read(db, current_user.id)
|
||||
await db.commit()
|
||||
return {"message": f"已标记 {count} 条消息为已读", "count": count}
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
组织关系 API
|
||||
品牌方管理代理商,代理商管理达人
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import (
|
||||
Brand, Agency, Creator,
|
||||
brand_agency_association, agency_creator_association,
|
||||
)
|
||||
from app.api.deps import get_current_user, get_current_brand, get_current_agency
|
||||
from app.schemas.organization import (
|
||||
BrandSummary,
|
||||
AgencySummary,
|
||||
CreatorSummary,
|
||||
InviteAgencyRequest,
|
||||
InviteCreatorRequest,
|
||||
UpdateAgencyPermissionRequest,
|
||||
AgencyListResponse,
|
||||
CreatorListResponse,
|
||||
BrandListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["组织关系"])
|
||||
|
||||
|
||||
# ===== 品牌方管理代理商 =====
|
||||
|
||||
|
||||
@router.get("/brand/agencies", response_model=AgencyListResponse)
|
||||
async def list_brand_agencies(
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询品牌方的代理商列表"""
|
||||
result = await db.execute(
|
||||
select(Brand)
|
||||
.options(selectinload(Brand.agencies))
|
||||
.where(Brand.id == brand.id)
|
||||
)
|
||||
brand_with_agencies = result.scalar_one()
|
||||
|
||||
items = [
|
||||
AgencySummary(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
logo=a.logo,
|
||||
contact_name=a.contact_name,
|
||||
force_pass_enabled=a.force_pass_enabled,
|
||||
)
|
||||
for a in brand_with_agencies.agencies
|
||||
]
|
||||
|
||||
return AgencyListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/brand/agencies", status_code=status.HTTP_201_CREATED)
|
||||
async def invite_agency(
|
||||
request: InviteAgencyRequest,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""邀请代理商加入品牌方"""
|
||||
# 查找代理商
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == request.agency_id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if not agency:
|
||||
raise HTTPException(status_code=404, detail="代理商不存在")
|
||||
|
||||
# 检查是否已关联
|
||||
brand_result = await db.execute(
|
||||
select(Brand)
|
||||
.options(selectinload(Brand.agencies))
|
||||
.where(Brand.id == brand.id)
|
||||
)
|
||||
brand_with_agencies = brand_result.scalar_one()
|
||||
|
||||
if agency in brand_with_agencies.agencies:
|
||||
raise HTTPException(status_code=400, detail="该代理商已加入")
|
||||
|
||||
brand_with_agencies.agencies.append(agency)
|
||||
await db.flush()
|
||||
|
||||
return {"message": "邀请成功", "agency_id": agency.id}
|
||||
|
||||
|
||||
@router.delete("/brand/agencies/{agency_id}")
|
||||
async def remove_agency(
|
||||
agency_id: str,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""移除代理商"""
|
||||
brand_result = await db.execute(
|
||||
select(Brand)
|
||||
.options(selectinload(Brand.agencies))
|
||||
.where(Brand.id == brand.id)
|
||||
)
|
||||
brand_with_agencies = brand_result.scalar_one()
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == agency_id)
|
||||
)
|
||||
agency = agency_result.scalar_one_or_none()
|
||||
|
||||
if agency and agency in brand_with_agencies.agencies:
|
||||
brand_with_agencies.agencies.remove(agency)
|
||||
await db.flush()
|
||||
|
||||
return {"message": "已移除"}
|
||||
|
||||
|
||||
@router.put("/brand/agencies/{agency_id}/permission")
|
||||
async def update_agency_permission(
|
||||
agency_id: str,
|
||||
request: UpdateAgencyPermissionRequest,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新代理商权限(如强制通过权)"""
|
||||
# 验证代理商是否属于该品牌
|
||||
brand_result = await db.execute(
|
||||
select(Brand)
|
||||
.options(selectinload(Brand.agencies))
|
||||
.where(Brand.id == brand.id)
|
||||
)
|
||||
brand_with_agencies = brand_result.scalar_one()
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == agency_id)
|
||||
)
|
||||
agency = agency_result.scalar_one_or_none()
|
||||
if not agency or agency not in brand_with_agencies.agencies:
|
||||
raise HTTPException(status_code=404, detail="代理商不存在或未加入")
|
||||
|
||||
agency.force_pass_enabled = request.force_pass_enabled
|
||||
await db.flush()
|
||||
|
||||
return {"message": "权限已更新"}
|
||||
|
||||
|
||||
# ===== 代理商管理达人 =====
|
||||
|
||||
|
||||
@router.get("/agency/creators", response_model=CreatorListResponse)
|
||||
async def list_agency_creators(
|
||||
agency: Agency = Depends(get_current_agency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询代理商的达人列表"""
|
||||
result = await db.execute(
|
||||
select(Agency)
|
||||
.options(selectinload(Agency.creators))
|
||||
.where(Agency.id == agency.id)
|
||||
)
|
||||
agency_with_creators = result.scalar_one()
|
||||
|
||||
items = [
|
||||
CreatorSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
avatar=c.avatar,
|
||||
douyin_account=c.douyin_account,
|
||||
xiaohongshu_account=c.xiaohongshu_account,
|
||||
bilibili_account=c.bilibili_account,
|
||||
)
|
||||
for c in agency_with_creators.creators
|
||||
]
|
||||
|
||||
return CreatorListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/agency/creators", status_code=status.HTTP_201_CREATED)
|
||||
async def invite_creator(
|
||||
request: InviteCreatorRequest,
|
||||
agency: Agency = Depends(get_current_agency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""邀请达人加入代理商"""
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.id == request.creator_id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
if not creator:
|
||||
raise HTTPException(status_code=404, detail="达人不存在")
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency)
|
||||
.options(selectinload(Agency.creators))
|
||||
.where(Agency.id == agency.id)
|
||||
)
|
||||
agency_with_creators = agency_result.scalar_one()
|
||||
|
||||
if creator in agency_with_creators.creators:
|
||||
raise HTTPException(status_code=400, detail="该达人已加入")
|
||||
|
||||
agency_with_creators.creators.append(creator)
|
||||
await db.flush()
|
||||
|
||||
return {"message": "邀请成功", "creator_id": creator.id}
|
||||
|
||||
|
||||
@router.delete("/agency/creators/{creator_id}")
|
||||
async def remove_creator(
|
||||
creator_id: str,
|
||||
agency: Agency = Depends(get_current_agency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""移除达人"""
|
||||
agency_result = await db.execute(
|
||||
select(Agency)
|
||||
.options(selectinload(Agency.creators))
|
||||
.where(Agency.id == agency.id)
|
||||
)
|
||||
agency_with_creators = agency_result.scalar_one()
|
||||
|
||||
creator_result = await db.execute(
|
||||
select(Creator).where(Creator.id == creator_id)
|
||||
)
|
||||
creator = creator_result.scalar_one_or_none()
|
||||
|
||||
if creator and creator in agency_with_creators.creators:
|
||||
agency_with_creators.creators.remove(creator)
|
||||
await db.flush()
|
||||
|
||||
return {"message": "已移除"}
|
||||
|
||||
|
||||
# ===== 代理商查看关联品牌方 =====
|
||||
|
||||
|
||||
@router.get("/agency/brands", response_model=BrandListResponse)
|
||||
async def list_agency_brands(
|
||||
agency: Agency = Depends(get_current_agency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询代理商关联的品牌方列表"""
|
||||
result = await db.execute(
|
||||
select(Agency)
|
||||
.options(selectinload(Agency.brands))
|
||||
.where(Agency.id == agency.id)
|
||||
)
|
||||
agency_with_brands = result.scalar_one()
|
||||
|
||||
items = [
|
||||
BrandSummary(
|
||||
id=b.id,
|
||||
name=b.name,
|
||||
logo=b.logo,
|
||||
contact_name=b.contact_name,
|
||||
)
|
||||
for b in agency_with_brands.brands
|
||||
]
|
||||
|
||||
return BrandListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
# ===== 搜索(用于邀请时查找) =====
|
||||
|
||||
|
||||
@router.get("/search/agencies")
|
||||
async def search_agencies(
|
||||
keyword: str = Query(..., min_length=1),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""搜索代理商(用于邀请)"""
|
||||
result = await db.execute(
|
||||
select(Agency)
|
||||
.where(Agency.name.ilike(f"%{keyword}%"))
|
||||
.limit(20)
|
||||
)
|
||||
agencies = list(result.scalars().all())
|
||||
|
||||
items = [
|
||||
AgencySummary(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
logo=a.logo,
|
||||
contact_name=a.contact_name,
|
||||
force_pass_enabled=a.force_pass_enabled,
|
||||
).model_dump()
|
||||
for a in agencies
|
||||
]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/search/creators")
|
||||
async def search_creators(
|
||||
keyword: str = Query(..., min_length=1),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""搜索达人(用于邀请)"""
|
||||
result = await db.execute(
|
||||
select(Creator)
|
||||
.where(Creator.name.ilike(f"%{keyword}%"))
|
||||
.limit(20)
|
||||
)
|
||||
creators = list(result.scalars().all())
|
||||
|
||||
items = [
|
||||
CreatorSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
avatar=c.avatar,
|
||||
douyin_account=c.douyin_account,
|
||||
xiaohongshu_account=c.xiaohongshu_account,
|
||||
bilibili_account=c.bilibili_account,
|
||||
).model_dump()
|
||||
for c in creators
|
||||
]
|
||||
return {"items": items, "total": len(items)}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
用户资料 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.api.deps import get_current_user
|
||||
from app.services.auth import verify_password, hash_password
|
||||
from app.schemas.profile import (
|
||||
ProfileResponse,
|
||||
ProfileUpdateRequest,
|
||||
ChangePasswordRequest,
|
||||
BrandProfile,
|
||||
AgencyProfile,
|
||||
CreatorProfile,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/profile", tags=["用户资料"])
|
||||
|
||||
|
||||
def _build_profile_response(user: User, brand=None, agency=None, creator=None) -> ProfileResponse:
|
||||
"""构建资料响应"""
|
||||
resp = ProfileResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
role=user.role.value,
|
||||
is_verified=user.is_verified,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
if brand:
|
||||
resp.brand = BrandProfile(
|
||||
id=brand.id,
|
||||
name=brand.name,
|
||||
logo=brand.logo,
|
||||
description=brand.description,
|
||||
contact_name=brand.contact_name,
|
||||
contact_phone=brand.contact_phone,
|
||||
contact_email=brand.contact_email,
|
||||
)
|
||||
if agency:
|
||||
resp.agency = AgencyProfile(
|
||||
id=agency.id,
|
||||
name=agency.name,
|
||||
logo=agency.logo,
|
||||
description=agency.description,
|
||||
contact_name=agency.contact_name,
|
||||
contact_phone=agency.contact_phone,
|
||||
contact_email=agency.contact_email,
|
||||
)
|
||||
if creator:
|
||||
resp.creator = CreatorProfile(
|
||||
id=creator.id,
|
||||
name=creator.name,
|
||||
avatar=creator.avatar,
|
||||
bio=creator.bio,
|
||||
douyin_account=creator.douyin_account,
|
||||
xiaohongshu_account=creator.xiaohongshu_account,
|
||||
bilibili_account=creator.bilibili_account,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
async def _get_role_entity(db: AsyncSession, user: User):
|
||||
"""根据角色获取对应实体"""
|
||||
if user.role == UserRole.BRAND:
|
||||
result = await db.execute(select(Brand).where(Brand.user_id == user.id))
|
||||
return result.scalar_one_or_none(), None, None
|
||||
elif user.role == UserRole.AGENCY:
|
||||
result = await db.execute(select(Agency).where(Agency.user_id == user.id))
|
||||
return None, result.scalar_one_or_none(), None
|
||||
elif user.role == UserRole.CREATOR:
|
||||
result = await db.execute(select(Creator).where(Creator.user_id == user.id))
|
||||
return None, None, result.scalar_one_or_none()
|
||||
return None, None, None
|
||||
|
||||
|
||||
@router.get("", response_model=ProfileResponse)
|
||||
async def get_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户资料"""
|
||||
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||
return _build_profile_response(current_user, brand, agency, creator)
|
||||
|
||||
|
||||
@router.put("", response_model=ProfileResponse)
|
||||
async def update_profile(
|
||||
request: ProfileUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新当前用户资料"""
|
||||
# 更新 User 表通用字段
|
||||
if request.name is not None:
|
||||
current_user.name = request.name
|
||||
if request.avatar is not None:
|
||||
current_user.avatar = request.avatar
|
||||
if request.phone is not None:
|
||||
current_user.phone = request.phone
|
||||
|
||||
# 更新角色表字段
|
||||
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||
|
||||
if current_user.role == UserRole.BRAND and brand:
|
||||
if request.name is not None:
|
||||
brand.name = request.name
|
||||
if request.description is not None:
|
||||
brand.description = request.description
|
||||
if request.contact_name is not None:
|
||||
brand.contact_name = request.contact_name
|
||||
if request.contact_phone is not None:
|
||||
brand.contact_phone = request.contact_phone
|
||||
if request.contact_email is not None:
|
||||
brand.contact_email = request.contact_email
|
||||
|
||||
elif current_user.role == UserRole.AGENCY and agency:
|
||||
if request.name is not None:
|
||||
agency.name = request.name
|
||||
if request.description is not None:
|
||||
agency.description = request.description
|
||||
if request.contact_name is not None:
|
||||
agency.contact_name = request.contact_name
|
||||
if request.contact_phone is not None:
|
||||
agency.contact_phone = request.contact_phone
|
||||
if request.contact_email is not None:
|
||||
agency.contact_email = request.contact_email
|
||||
|
||||
elif current_user.role == UserRole.CREATOR and creator:
|
||||
if request.name is not None:
|
||||
creator.name = request.name
|
||||
if request.avatar is not None:
|
||||
creator.avatar = request.avatar
|
||||
if request.bio is not None:
|
||||
creator.bio = request.bio
|
||||
if request.douyin_account is not None:
|
||||
creator.douyin_account = request.douyin_account
|
||||
if request.xiaohongshu_account is not None:
|
||||
creator.xiaohongshu_account = request.xiaohongshu_account
|
||||
if request.bilibili_account is not None:
|
||||
creator.bilibili_account = request.bilibili_account
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 重新查询返回最新数据
|
||||
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||
return _build_profile_response(current_user, brand, agency, creator)
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
request: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""修改密码"""
|
||||
if not verify_password(request.old_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码不正确",
|
||||
)
|
||||
|
||||
current_user.password_hash = hash_password(request.new_password)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
项目 API
|
||||
品牌方创建和管理项目,分配代理商
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.project import Project, project_agency_association
|
||||
from app.models.task import Task
|
||||
from app.models.organization import Brand, Agency
|
||||
from app.api.deps import get_current_user, get_current_brand, get_current_agency
|
||||
from app.schemas.project import (
|
||||
ProjectCreateRequest,
|
||||
ProjectUpdateRequest,
|
||||
ProjectAssignAgencyRequest,
|
||||
ProjectResponse,
|
||||
ProjectListResponse,
|
||||
AgencySummary,
|
||||
)
|
||||
from app.services.auth import generate_id
|
||||
from app.services.message_service import create_message
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["项目"])
|
||||
|
||||
|
||||
async def _project_to_response(project: Project, db: AsyncSession) -> ProjectResponse:
|
||||
"""将项目模型转换为响应"""
|
||||
# 获取任务数量
|
||||
count_result = await db.execute(
|
||||
select(func.count(Task.id)).where(Task.project_id == project.id)
|
||||
)
|
||||
task_count = count_result.scalar() or 0
|
||||
|
||||
agencies = []
|
||||
if project.agencies:
|
||||
agencies = [
|
||||
AgencySummary(id=a.id, name=a.name, logo=a.logo)
|
||||
for a in project.agencies
|
||||
]
|
||||
|
||||
return ProjectResponse(
|
||||
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,
|
||||
start_date=project.start_date,
|
||||
deadline=project.deadline,
|
||||
agencies=agencies,
|
||||
task_count=task_count,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
request: ProjectCreateRequest,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
创建项目(品牌方操作)
|
||||
"""
|
||||
project = Project(
|
||||
id=generate_id("PJ"),
|
||||
brand_id=brand.id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
platform=request.platform,
|
||||
start_date=request.start_date,
|
||||
deadline=request.deadline,
|
||||
status="active",
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
|
||||
if request.agency_ids:
|
||||
for agency_id in request.agency_ids:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == agency_id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if agency:
|
||||
await db.execute(
|
||||
project_agency_association.insert().values(
|
||||
project_id=project.id,
|
||||
agency_id=agency.id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
await db.refresh(project)
|
||||
|
||||
# 重新加载关联
|
||||
result = await db.execute(
|
||||
select(Project)
|
||||
.options(selectinload(Project.brand), selectinload(Project.agencies))
|
||||
.where(Project.id == project.id)
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 给被分配的代理商发送新项目通知
|
||||
if project.agencies:
|
||||
for agency in project.agencies:
|
||||
agency_user_result = await db.execute(
|
||||
select(User).where(User.id == agency.user_id)
|
||||
)
|
||||
agency_user = agency_user_result.scalar_one_or_none()
|
||||
if agency_user:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_user.id,
|
||||
type="new_task",
|
||||
title="新项目分配",
|
||||
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}」",
|
||||
related_project_id=project.id,
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectListResponse)
|
||||
async def list_projects(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查询项目列表
|
||||
|
||||
- 品牌方: 查看自己创建的项目
|
||||
- 代理商: 查看被分配的项目
|
||||
"""
|
||||
if current_user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == current_user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
raise HTTPException(status_code=404, detail="品牌方信息不存在")
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(selectinload(Project.brand), selectinload(Project.agencies))
|
||||
.where(Project.brand_id == brand.id)
|
||||
)
|
||||
count_query = select(func.count(Project.id)).where(Project.brand_id == brand.id)
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Project.status == status_filter)
|
||||
count_query = count_query.where(Project.status == status_filter)
|
||||
|
||||
elif current_user.role == UserRole.AGENCY:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == current_user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if not agency:
|
||||
raise HTTPException(status_code=404, detail="代理商信息不存在")
|
||||
|
||||
# 通过关联表查询
|
||||
project_ids_query = (
|
||||
select(project_agency_association.c.project_id)
|
||||
.where(project_agency_association.c.agency_id == agency.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 ProjectListResponse(items=[], total=0, page=page, page_size=page_size)
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(selectinload(Project.brand), selectinload(Project.agencies))
|
||||
.where(Project.id.in_(project_ids))
|
||||
)
|
||||
count_query = select(func.count(Project.id)).where(Project.id.in_(project_ids))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Project.status == status_filter)
|
||||
count_query = count_query.where(Project.status == status_filter)
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="达人无权查看项目列表")
|
||||
|
||||
query = query.order_by(Project.created_at.desc())
|
||||
|
||||
# 总数
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
projects = list(result.scalars().all())
|
||||
|
||||
items = []
|
||||
for p in projects:
|
||||
items.append(await _project_to_response(p, db))
|
||||
|
||||
return ProjectListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询项目详情"""
|
||||
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.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="无权访问此项目")
|
||||
elif 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="无权访问此项目")
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="无权访问此项目")
|
||||
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
request: ProjectUpdateRequest,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新项目(品牌方操作)"""
|
||||
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 project.brand_id != brand.id:
|
||||
raise HTTPException(status_code=403, detail="无权修改此项目")
|
||||
|
||||
if request.name is not None:
|
||||
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:
|
||||
project.deadline = request.deadline
|
||||
if request.status is not None:
|
||||
project.status = request.status
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@router.post("/{project_id}/agencies", response_model=ProjectResponse)
|
||||
async def assign_agencies(
|
||||
project_id: str,
|
||||
request: ProjectAssignAgencyRequest,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分配代理商到项目(品牌方操作)"""
|
||||
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 project.brand_id != brand.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此项目")
|
||||
|
||||
newly_assigned = []
|
||||
for agency_id in request.agency_ids:
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == agency_id)
|
||||
)
|
||||
agency = agency_result.scalar_one_or_none()
|
||||
if agency and agency not in project.agencies:
|
||||
project.agencies.append(agency)
|
||||
newly_assigned.append(agency)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
|
||||
# 给新分配的代理商发送通知
|
||||
for agency in newly_assigned:
|
||||
agency_user_result = await db.execute(
|
||||
select(User).where(User.id == agency.user_id)
|
||||
)
|
||||
agency_user = agency_user_result.scalar_one_or_none()
|
||||
if agency_user:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_user.id,
|
||||
type="new_task",
|
||||
title="新项目分配",
|
||||
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}」",
|
||||
related_project_id=project.id,
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/agencies/{agency_id}", response_model=ProjectResponse)
|
||||
async def remove_agency_from_project(
|
||||
project_id: str,
|
||||
agency_id: str,
|
||||
brand: Brand = Depends(get_current_brand),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""从项目移除代理商(品牌方操作)"""
|
||||
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 project.brand_id != brand.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此项目")
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == agency_id)
|
||||
)
|
||||
agency = agency_result.scalar_one_or_none()
|
||||
if agency and agency in project.agencies:
|
||||
project.agencies.remove(agency)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
|
||||
return await _project_to_response(project, db)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
SSE (Server-Sent Events) 实时推送 API
|
||||
用于推送审核进度等实时通知
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import AsyncGenerator, Optional, Set
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.api.deps import get_current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter(prefix="/sse", tags=["实时推送"])
|
||||
|
||||
# 存储活跃的客户端连接
|
||||
# 结构: {user_id: set of AsyncGenerator}
|
||||
active_connections: dict[str, Set[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
async def add_connection(user_id: str, queue: asyncio.Queue):
|
||||
"""添加客户端连接"""
|
||||
if user_id not in active_connections:
|
||||
active_connections[user_id] = set()
|
||||
active_connections[user_id].add(queue)
|
||||
|
||||
|
||||
async def remove_connection(user_id: str, queue: asyncio.Queue):
|
||||
"""移除客户端连接"""
|
||||
if user_id in active_connections:
|
||||
active_connections[user_id].discard(queue)
|
||||
if not active_connections[user_id]:
|
||||
del active_connections[user_id]
|
||||
|
||||
|
||||
async def send_to_user(user_id: str, event: str, data: dict):
|
||||
"""发送消息给指定用户的所有连接"""
|
||||
if user_id in active_connections:
|
||||
message = {
|
||||
"event": event,
|
||||
"data": data,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
for queue in active_connections[user_id]:
|
||||
await queue.put(message)
|
||||
|
||||
|
||||
async def broadcast_to_role(role: UserRole, event: str, data: dict, db: AsyncSession):
|
||||
"""广播消息给指定角色的所有用户"""
|
||||
# 这里简化处理,实际应该批量查询
|
||||
# 在生产环境中应该使用 Redis 等消息队列
|
||||
pass
|
||||
|
||||
|
||||
async def event_generator(user_id: str, queue: asyncio.Queue) -> AsyncGenerator[dict, None]:
|
||||
"""SSE 事件生成器"""
|
||||
try:
|
||||
await add_connection(user_id, queue)
|
||||
|
||||
# 发送连接成功消息
|
||||
yield {
|
||||
"event": "connected",
|
||||
"data": json.dumps({
|
||||
"message": "连接成功",
|
||||
"user_id": user_id,
|
||||
}),
|
||||
}
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 等待消息,超时后发送心跳
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield {
|
||||
"event": message["event"],
|
||||
"data": json.dumps(message["data"]),
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
# 发送心跳保持连接
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": json.dumps({"timestamp": datetime.utcnow().isoformat()}),
|
||||
}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await remove_connection(user_id, queue)
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def sse_events(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
SSE 事件流
|
||||
|
||||
- 客户端通过此端点订阅实时事件
|
||||
- 支持的事件类型:
|
||||
- connected: 连接成功
|
||||
- heartbeat: 心跳
|
||||
- task_updated: 任务状态更新
|
||||
- review_progress: AI 审核进度
|
||||
- review_completed: AI 审核完成
|
||||
- new_task: 新任务分配
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(current_user.id, queue),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# ===== 推送工具函数(供其他模块调用) =====
|
||||
|
||||
|
||||
async def notify_task_updated(task_id: str, user_ids: list[str], data: dict):
|
||||
"""
|
||||
通知任务状态更新
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
user_ids: 需要通知的用户 ID 列表
|
||||
data: 推送数据
|
||||
"""
|
||||
for user_id in user_ids:
|
||||
await send_to_user(user_id, "task_updated", {
|
||||
"task_id": task_id,
|
||||
**data,
|
||||
})
|
||||
|
||||
|
||||
async def notify_review_progress(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
progress: int,
|
||||
current_step: str,
|
||||
review_type: str, # "script" or "video"
|
||||
):
|
||||
"""
|
||||
通知 AI 审核进度
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
user_id: 达人用户 ID
|
||||
progress: 进度百分比 (0-100)
|
||||
current_step: 当前步骤描述
|
||||
review_type: 审核类型
|
||||
"""
|
||||
await send_to_user(user_id, "review_progress", {
|
||||
"task_id": task_id,
|
||||
"review_type": review_type,
|
||||
"progress": progress,
|
||||
"current_step": current_step,
|
||||
})
|
||||
|
||||
|
||||
async def notify_review_completed(
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
review_type: str,
|
||||
score: int,
|
||||
violations_count: int,
|
||||
):
|
||||
"""
|
||||
通知 AI 审核完成
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
user_id: 达人用户 ID
|
||||
review_type: 审核类型
|
||||
score: 审核分数
|
||||
violations_count: 违规数量
|
||||
"""
|
||||
await send_to_user(user_id, "review_completed", {
|
||||
"task_id": task_id,
|
||||
"review_type": review_type,
|
||||
"score": score,
|
||||
"violations_count": violations_count,
|
||||
})
|
||||
|
||||
|
||||
async def notify_new_task(
|
||||
task_id: str,
|
||||
creator_user_id: str,
|
||||
task_name: str,
|
||||
project_name: str,
|
||||
):
|
||||
"""
|
||||
通知新任务分配
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
creator_user_id: 达人用户 ID
|
||||
task_name: 任务名称
|
||||
project_name: 项目名称
|
||||
"""
|
||||
await send_to_user(creator_user_id, "new_task", {
|
||||
"task_id": task_id,
|
||||
"task_name": task_name,
|
||||
"project_name": project_name,
|
||||
})
|
||||
|
||||
|
||||
async def notify_review_decision(
|
||||
task_id: str,
|
||||
creator_user_id: str,
|
||||
review_type: str, # "script" or "video"
|
||||
reviewer_type: str, # "agency" or "brand"
|
||||
action: str, # "pass", "reject", "force_pass"
|
||||
comment: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
通知审核决策
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
creator_user_id: 达人用户 ID
|
||||
review_type: 审核类型
|
||||
reviewer_type: 审核者类型
|
||||
action: 审核动作
|
||||
comment: 审核意见
|
||||
"""
|
||||
await send_to_user(creator_user_id, "review_decision", {
|
||||
"task_id": task_id,
|
||||
"review_type": review_type,
|
||||
"reviewer_type": reviewer_type,
|
||||
"action": action,
|
||||
"comment": comment,
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
文件上传 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
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.oss import generate_upload_policy, get_file_url, generate_presigned_url
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||
|
||||
|
||||
class UploadPolicyRequest(BaseModel):
|
||||
"""获取上传凭证请求"""
|
||||
file_type: str = "general" # script, video, image, general
|
||||
file_name: Optional[str] = None
|
||||
|
||||
|
||||
class UploadPolicyResponse(BaseModel):
|
||||
"""TOS 直传凭证响应"""
|
||||
x_tos_algorithm: str
|
||||
x_tos_credential: str
|
||||
x_tos_date: str
|
||||
x_tos_signature: str
|
||||
policy: str
|
||||
host: str
|
||||
dir: str
|
||||
expire: int
|
||||
max_size_mb: int
|
||||
|
||||
|
||||
class FileUploadedRequest(BaseModel):
|
||||
"""文件上传完成回调"""
|
||||
file_key: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
file_type: str
|
||||
|
||||
|
||||
class FileUploadedResponse(BaseModel):
|
||||
"""文件上传完成响应"""
|
||||
url: str
|
||||
file_key: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
file_type: str
|
||||
|
||||
|
||||
@router.post("/policy", response_model=UploadPolicyResponse)
|
||||
async def get_upload_policy(
|
||||
request: UploadPolicyRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取 TOS 直传凭证
|
||||
|
||||
前端使用此凭证直接上传文件到火山引擎 TOS,无需经过后端。
|
||||
|
||||
文件类型说明:
|
||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||
- video: 视频文件 (mp4, mov, webm)
|
||||
- image: 图片文件 (jpg, png, gif)
|
||||
- general: 通用文件
|
||||
"""
|
||||
# 根据文件类型设置上传目录
|
||||
now = datetime.now()
|
||||
base_dir = f"uploads/{now.year}/{now.month:02d}"
|
||||
|
||||
if request.file_type == "script":
|
||||
upload_dir = f"{base_dir}/scripts/"
|
||||
elif request.file_type == "video":
|
||||
upload_dir = f"{base_dir}/videos/"
|
||||
elif request.file_type == "image":
|
||||
upload_dir = f"{base_dir}/images/"
|
||||
else:
|
||||
upload_dir = f"{base_dir}/files/"
|
||||
|
||||
try:
|
||||
policy = generate_upload_policy(
|
||||
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
||||
expire_seconds=3600,
|
||||
upload_dir=upload_dir,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
return UploadPolicyResponse(
|
||||
x_tos_algorithm=policy["x_tos_algorithm"],
|
||||
x_tos_credential=policy["x_tos_credential"],
|
||||
x_tos_date=policy["x_tos_date"],
|
||||
x_tos_signature=policy["x_tos_signature"],
|
||||
policy=policy["policy"],
|
||||
host=policy["host"],
|
||||
dir=policy["dir"],
|
||||
expire=policy["expire"],
|
||||
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=FileUploadedResponse)
|
||||
async def file_uploaded(
|
||||
request: FileUploadedRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
文件上传完成回调
|
||||
|
||||
前端上传完成后调用此接口,获取文件的完整 URL。
|
||||
"""
|
||||
url = get_file_url(request.file_key)
|
||||
|
||||
return FileUploadedResponse(
|
||||
url=url,
|
||||
file_key=request.file_key,
|
||||
file_name=request.file_name,
|
||||
file_size=request.file_size,
|
||||
file_type=request.file_type,
|
||||
)
|
||||
|
||||
|
||||
class SignedUrlResponse(BaseModel):
|
||||
"""签名 URL 响应"""
|
||||
signed_url: str
|
||||
expire_seconds: int
|
||||
|
||||
|
||||
@router.get("/sign-url", response_model=SignedUrlResponse)
|
||||
async def get_signed_url(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取私有桶文件的预签名访问 URL
|
||||
|
||||
前端在展示/下载文件前调用此接口,获取带签名的临时访问链接。
|
||||
支持传入完整 URL 或 file_key。
|
||||
"""
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
|
||||
# 如果传入的是完整 URL,先解析出 file_key
|
||||
file_key = url
|
||||
if url.startswith("http"):
|
||||
file_key = parse_file_key_from_url(url)
|
||||
|
||||
if not file_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的文件路径",
|
||||
)
|
||||
|
||||
try:
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
return SignedUrlResponse(
|
||||
signed_url=signed_url,
|
||||
expire_seconds=expire,
|
||||
)
|
||||
|
||||
|
||||
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(...),
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
视频审核 API
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
VideoReviewRequest,
|
||||
VideoReviewSubmitResponse,
|
||||
VideoReviewProgressResponse,
|
||||
VideoReviewResultResponse,
|
||||
TaskStatus,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
ViolationSource,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review",
|
||||
response_model=VideoReviewSubmitResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def submit_video_review(
|
||||
request: VideoReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewSubmitResponse:
|
||||
"""
|
||||
提交视频审核
|
||||
|
||||
返回 202 Accepted,异步处理
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
review_id = f"review-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# 创建审核任务
|
||||
task = ReviewTask(
|
||||
id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
platform=DBPlatform(request.platform.value),
|
||||
brand_id=request.brand_id,
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
progress=0,
|
||||
current_step="等待处理",
|
||||
competitors=request.competitors,
|
||||
requirements=request.requirements,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 触发 Celery 异步任务
|
||||
try:
|
||||
from app.tasks.review import process_video_review_task
|
||||
process_video_review_task.delay(
|
||||
review_id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
brand_id=request.brand_id,
|
||||
platform=request.platform.value,
|
||||
)
|
||||
except Exception:
|
||||
# Celery 不可用时,任务保持 PENDING 状态
|
||||
# 后续可通过定时任务或手动触发处理
|
||||
pass
|
||||
|
||||
return VideoReviewSubmitResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/{review_id}/progress",
|
||||
response_model=VideoReviewProgressResponse,
|
||||
)
|
||||
async def get_review_progress(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewProgressResponse:
|
||||
"""
|
||||
查询审核进度
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
return VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{review_id}/result")
|
||||
async def get_review_result(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查询审核结果
|
||||
|
||||
- 未完成:返回 202 + 进度结构
|
||||
- 已完成:返回 200 + 结果结构
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
# 未完成:返回 202 + 进度
|
||||
if task.status in [DBTaskStatus.PENDING, DBTaskStatus.PROCESSING]:
|
||||
progress_response = VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
content=progress_response.model_dump(),
|
||||
)
|
||||
|
||||
# 失败:返回错误信息
|
||||
if task.status == DBTaskStatus.FAILED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task.error_message or "审核任务失败",
|
||||
)
|
||||
|
||||
# 已完成:返回 200 + 结果
|
||||
violations = []
|
||||
if task.violations:
|
||||
for v in task.violations:
|
||||
violations.append(Violation(**v))
|
||||
|
||||
soft_warnings = []
|
||||
if task.soft_warnings:
|
||||
for w in task.soft_warnings:
|
||||
soft_warnings.append(SoftRiskWarning(**w))
|
||||
|
||||
return VideoReviewResultResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.COMPLETED,
|
||||
score=task.score or 100,
|
||||
summary=task.summary or "审核完成",
|
||||
violations=violations,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Celery 应用配置
|
||||
后台任务队列
|
||||
"""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
"miaosi",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.review"],
|
||||
)
|
||||
|
||||
# 配置
|
||||
celery_app.conf.update(
|
||||
# 任务序列化
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
|
||||
# 时区
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
|
||||
# 任务配置
|
||||
task_track_started=True,
|
||||
task_time_limit=600, # 10 分钟超时
|
||||
task_soft_time_limit=540, # 9 分钟软超时
|
||||
|
||||
# 结果配置
|
||||
result_expires=3600, # 结果保留 1 小时
|
||||
|
||||
# 并发配置
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_concurrency=4,
|
||||
|
||||
# 重试配置
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
|
||||
# 路由配置
|
||||
task_routes={
|
||||
"app.tasks.review.*": {"queue": "review"},
|
||||
},
|
||||
|
||||
# 队列配置
|
||||
task_default_queue="default",
|
||||
|
||||
# 定时任务
|
||||
beat_schedule={
|
||||
# 每小时清理过期临时文件
|
||||
"cleanup-old-files": {
|
||||
"task": "app.tasks.review.cleanup_old_files_task",
|
||||
"schedule": crontab(minute=0), # 每小时整点执行
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""应用配置"""
|
||||
import warnings
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用设置"""
|
||||
# 应用
|
||||
APP_NAME: str = "秒思智能审核平台"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: str = "development" # development | staging | production
|
||||
|
||||
# CORS(逗号分隔的允许来源列表)
|
||||
CORS_ORIGINS: str = "http://localhost:3000"
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# AI 服务(使用 OneAPI/OneInAll 等中转服务商,不直连厂商)
|
||||
# 中转服务商统一了不同 AI 厂商的接口,只需配置中转商的 API
|
||||
AI_PROVIDER: str = "oneapi" # oneapi | oneinall | openrouter 等中转服务商
|
||||
AI_API_KEY: str = "" # 中转服务商的 API Key
|
||||
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
||||
|
||||
# 火山引擎 TOS 配置
|
||||
TOS_ACCESS_KEY_ID: str = ""
|
||||
TOS_SECRET_ACCESS_KEY: str = ""
|
||||
TOS_REGION: str = "cn-beijing"
|
||||
TOS_BUCKET_NAME: str = "miaosi-files"
|
||||
TOS_ENDPOINT: str = "" # 自定义 Endpoint,空则用默认 tos-cn-{region}.volces.com
|
||||
TOS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 TOS 源站
|
||||
|
||||
# 邮件 SMTP
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_NAME: str = "秒思智能审核平台"
|
||||
SMTP_USE_SSL: bool = True
|
||||
|
||||
# 验证码
|
||||
VERIFICATION_CODE_EXPIRE_MINUTES: int = 5
|
||||
VERIFICATION_CODE_LENGTH: int = 6
|
||||
|
||||
# 加密密钥
|
||||
ENCRYPTION_KEY: str = ""
|
||||
|
||||
# 文件上传限制
|
||||
MAX_FILE_SIZE_MB: int = 500 # 最大文件大小 500MB
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if self.SECRET_KEY == "your-secret-key-change-in-production":
|
||||
warnings.warn(
|
||||
"SECRET_KEY 使用默认值,请在 .env 中设置安全的密钥!",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if not self.ENCRYPTION_KEY:
|
||||
warnings.warn(
|
||||
"ENCRYPTION_KEY 未设置,API 密钥将无法安全存储!",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置单例"""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 导入所有模型,确保在创建表时被注册
|
||||
from app.models.base import Base
|
||||
from app.models import (
|
||||
# 用户与组织
|
||||
User,
|
||||
UserRole,
|
||||
Brand,
|
||||
Agency,
|
||||
Creator,
|
||||
# 项目与任务
|
||||
Project,
|
||||
Task,
|
||||
TaskStage,
|
||||
TaskStatus,
|
||||
Brief,
|
||||
# AI 配置
|
||||
AIConfig,
|
||||
# 审核
|
||||
ReviewTask,
|
||||
# 规则
|
||||
ForbiddenWord,
|
||||
WhitelistItem,
|
||||
Competitor,
|
||||
# 审计日志
|
||||
AuditLog,
|
||||
# 兼容
|
||||
Tenant,
|
||||
)
|
||||
|
||||
# 创建异步引擎
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""获取数据库会话依赖"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""初始化数据库(创建所有表)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db():
|
||||
"""删除所有表(仅用于测试)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
# 导出所有模型,供其他模块使用
|
||||
__all__ = [
|
||||
"Base",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"get_db",
|
||||
"init_db",
|
||||
"drop_db",
|
||||
# 用户与组织
|
||||
"User",
|
||||
"UserRole",
|
||||
"Brand",
|
||||
"Agency",
|
||||
"Creator",
|
||||
# 项目与任务
|
||||
"Project",
|
||||
"Task",
|
||||
"TaskStage",
|
||||
"TaskStatus",
|
||||
"Brief",
|
||||
# AI 配置
|
||||
"AIConfig",
|
||||
# 审核
|
||||
"ReviewTask",
|
||||
# 规则
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
# 审计日志
|
||||
"AuditLog",
|
||||
# 兼容
|
||||
"Tenant",
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""结构化日志配置"""
|
||||
import logging
|
||||
import sys
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""配置结构化日志"""
|
||||
log_level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||
|
||||
# Root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Remove default handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Console handler with structured format
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(log_level)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
# Quiet down noisy libraries
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(
|
||||
logging.INFO if settings.DEBUG else logging.WARNING
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
return root_logger
|
||||
@@ -0,0 +1,92 @@
|
||||
"""FastAPI 应用入口"""
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.config import settings
|
||||
from app.logging_config import setup_logging
|
||||
from app.middleware.rate_limit import RateLimitMiddleware
|
||||
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export, profile, messages
|
||||
|
||||
# Initialize logging
|
||||
logger = setup_logging()
|
||||
|
||||
# 环境判断
|
||||
_is_production = settings.ENVIRONMENT == "production"
|
||||
|
||||
# 创建应用(生产环境禁用 API 文档)
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="AI 营销内容合规审核平台 API",
|
||||
docs_url=None if _is_production else "/docs",
|
||||
redoc_url=None if _is_production else "/redoc",
|
||||
)
|
||||
|
||||
# CORS 配置(从环境变量读取允许的来源)
|
||||
_cors_origins = [
|
||||
origin.strip()
|
||||
for origin in settings.CORS_ORIGINS.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Security headers middleware
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
if _is_production:
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=63072000; includeSubDomains; preload"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Rate limiting (仅生产环境启用)
|
||||
if _is_production:
|
||||
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(upload.router, prefix="/api/v1")
|
||||
app.include_router(scripts.router, prefix="/api/v1")
|
||||
app.include_router(videos.router, prefix="/api/v1")
|
||||
app.include_router(tasks.router, prefix="/api/v1")
|
||||
app.include_router(rules.router, prefix="/api/v1")
|
||||
app.include_router(ai_config.router, prefix="/api/v1")
|
||||
app.include_router(sse.router, prefix="/api/v1")
|
||||
app.include_router(projects.router, prefix="/api/v1")
|
||||
app.include_router(briefs.router, prefix="/api/v1")
|
||||
app.include_router(organizations.router, prefix="/api/v1")
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
app.include_router(export.router, prefix="/api/v1")
|
||||
app.include_router(profile.router, prefix="/api/v1")
|
||||
app.include_router(messages.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME}",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "disabled" if _is_production else "/docs",
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
速率限制中间件
|
||||
基于内存的滑动窗口计数器,支持按路径自定义限制和标准响应头。
|
||||
"""
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
速率限制中间件
|
||||
|
||||
- 默认: 60 次/分钟 per IP
|
||||
- 按路径配置不同限制 (path_limits)
|
||||
- 返回标准 X-RateLimit-* 响应头
|
||||
"""
|
||||
|
||||
# Path-specific rate limits (requests per window).
|
||||
# Paths not listed here fall back to ``default_limit``.
|
||||
DEFAULT_PATH_LIMITS: dict[str, int] = {
|
||||
# Auth endpoints — prevent brute-force / abuse
|
||||
"/api/v1/auth/login": 10,
|
||||
"/api/v1/auth/register": 10,
|
||||
"/api/v1/auth/send-code": 5,
|
||||
"/api/v1/auth/reset-password": 5,
|
||||
# Upload — bandwidth / storage cost
|
||||
"/api/v1/upload/policy": 30,
|
||||
# AI review — service cost + compute
|
||||
"/api/v1/scripts/review": 10,
|
||||
"/api/v1/videos/review": 5,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
default_limit: int = 60,
|
||||
window_seconds: int = 60,
|
||||
path_limits: dict[str, int] | None = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
self.default_limit = default_limit
|
||||
self.window_seconds = window_seconds
|
||||
self.requests: dict[str, list[float]] = defaultdict(list)
|
||||
# Merge caller-supplied overrides on top of the built-in defaults.
|
||||
self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
|
||||
if path_limits:
|
||||
self.path_limits.update(path_limits)
|
||||
|
||||
def _get_limit(self, path: str) -> int:
|
||||
"""Return the rate limit for *path*, falling back to *default_limit*."""
|
||||
return self.path_limits.get(path, self.default_limit)
|
||||
|
||||
def _make_key(self, client_ip: str, path: str) -> str:
|
||||
"""Build the bucket key.
|
||||
|
||||
Paths with a custom limit are bucketed per-IP per-path so that
|
||||
hitting one endpoint does not consume the quota of another.
|
||||
Default paths share a single per-IP bucket.
|
||||
"""
|
||||
if path in self.path_limits:
|
||||
return f"{client_ip}:{path}"
|
||||
return client_ip
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
path = request.url.path
|
||||
now = time.time()
|
||||
|
||||
limit = self._get_limit(path)
|
||||
key = self._make_key(client_ip, path)
|
||||
|
||||
# Clean old entries outside the sliding window
|
||||
window_start = now - self.window_seconds
|
||||
self.requests[key] = [t for t in self.requests[key] if t > window_start]
|
||||
|
||||
current_count = len(self.requests[key])
|
||||
remaining = max(0, limit - current_count)
|
||||
|
||||
# Seconds until the oldest request in the window expires
|
||||
if self.requests[key]:
|
||||
reset_seconds = int(self.requests[key][0] - window_start)
|
||||
else:
|
||||
reset_seconds = self.window_seconds
|
||||
|
||||
# Build common rate-limit headers
|
||||
rate_headers = {
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": str(max(0, remaining - 1) if remaining > 0 else 0),
|
||||
"X-RateLimit-Reset": str(reset_seconds),
|
||||
}
|
||||
|
||||
# Check limit
|
||||
if current_count >= limit:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后再试"},
|
||||
headers={
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_seconds),
|
||||
"Retry-After": str(reset_seconds),
|
||||
},
|
||||
)
|
||||
|
||||
# Record request
|
||||
self.requests[key].append(now)
|
||||
|
||||
# Periodic cleanup (keep memory bounded)
|
||||
if len(self.requests) > 10000:
|
||||
self._cleanup(now)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Attach rate-limit headers to successful responses
|
||||
response.headers["X-RateLimit-Limit"] = rate_headers["X-RateLimit-Limit"]
|
||||
response.headers["X-RateLimit-Remaining"] = rate_headers["X-RateLimit-Remaining"]
|
||||
response.headers["X-RateLimit-Reset"] = rate_headers["X-RateLimit-Reset"]
|
||||
|
||||
return response
|
||||
|
||||
def _cleanup(self, now: float):
|
||||
"""Clean up expired entries"""
|
||||
window_start = now - self.window_seconds
|
||||
expired_keys = [
|
||||
k for k, v in self.requests.items()
|
||||
if not v or v[-1] < window_start
|
||||
]
|
||||
for k in expired_keys:
|
||||
del self.requests[k]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
数据库模型
|
||||
导出所有 ORM 模型
|
||||
"""
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator, brand_agency_association, agency_creator_association
|
||||
from app.models.project import Project, project_agency_association
|
||||
from app.models.task import Task, TaskStage, TaskStatus
|
||||
from app.models.brief import Brief
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask, Platform
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor, PlatformRule, RuleStatus
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.message import Message
|
||||
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
# 用户与组织
|
||||
"User",
|
||||
"UserRole",
|
||||
"Brand",
|
||||
"Agency",
|
||||
"Creator",
|
||||
"brand_agency_association",
|
||||
"agency_creator_association",
|
||||
# 项目与任务
|
||||
"Project",
|
||||
"project_agency_association",
|
||||
"Task",
|
||||
"TaskStage",
|
||||
"TaskStatus",
|
||||
"Brief",
|
||||
# AI 配置
|
||||
"AIConfig",
|
||||
# 审核
|
||||
"ReviewTask",
|
||||
"Platform",
|
||||
# 规则
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
"PlatformRule",
|
||||
"RuleStatus",
|
||||
# 审计日志
|
||||
"AuditLog",
|
||||
# 消息
|
||||
"Message",
|
||||
# 兼容
|
||||
"Tenant",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
AI 配置模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, Float, Integer, ForeignKey, DateTime
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AIConfig(Base, TimestampMixin):
|
||||
"""AI 服务配置表"""
|
||||
__tablename__ = "ai_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 提供商配置
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 模型配置 (JSON)
|
||||
# {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"}
|
||||
models: Mapped[dict] = mapped_column(JSONType, nullable=False)
|
||||
|
||||
# 参数配置
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=2000, nullable=False)
|
||||
|
||||
# 可用模型缓存 (JSON)
|
||||
available_models: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 测试结果
|
||||
last_test_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
last_test_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 配置状态
|
||||
is_configured: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="ai_config")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AIConfig(tenant_id={self.tenant_id}, provider={self.provider})>"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""审计日志模型"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Text, DateTime, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计日志表 - 记录所有重要操作"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 操作信息
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # login, logout, create_project, review_task, etc.
|
||||
resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # user, project, task, brief, etc.
|
||||
resource_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
# 操作者
|
||||
user_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
user_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
user_role: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# 详情
|
||||
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON string with extra info
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
|
||||
# 时间
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
数据库模型基类
|
||||
提供公共字段和功能
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""声明基类"""
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""时间戳 Mixin,提供 created_at 和 updated_at 字段"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Brief 模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.types import JSONType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.project import Project
|
||||
|
||||
|
||||
class Brief(Base, TimestampMixin):
|
||||
"""Brief 文档表"""
|
||||
__tablename__ = "briefs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 原始文件
|
||||
file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=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)
|
||||
|
||||
# 竞品: ["竞品A", "竞品B", ...]
|
||||
competitors: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 品牌调性要求
|
||||
brand_tone: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 时长要求(秒)
|
||||
min_duration: Mapped[Optional[int]] = mapped_column(nullable=True)
|
||||
max_duration: Mapped[Optional[int]] = mapped_column(nullable=True)
|
||||
|
||||
# 其他要求(自由文本)
|
||||
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")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Brief(id={self.id}, project_id={self.project_id})>"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
消息/通知模型
|
||||
"""
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Message(Base, TimestampMixin):
|
||||
"""消息表"""
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
|
||||
# 接收者
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 消息类型: invite, new_task, pass, reject, appeal, system 等
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# 消息内容
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 已读状态
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# 关联信息(可选)
|
||||
related_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
related_project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
sender_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_messages_user_id", "user_id"),
|
||||
Index("idx_messages_user_read", "user_id", "is_read"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Message(id={self.id}, user_id={self.user_id}, type={self.type})>"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
组织模型:品牌方、代理商、达人
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, Text, ForeignKey, DateTime, Table, Column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.project import Project
|
||||
|
||||
|
||||
# 品牌方-代理商 关联表(多对多)
|
||||
brand_agency_association = Table(
|
||||
"brand_agency",
|
||||
Base.metadata,
|
||||
Column("brand_id", String(64), ForeignKey("brands.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("agency_id", String(64), ForeignKey("agencies.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=datetime.utcnow),
|
||||
Column("is_active", Boolean, default=True),
|
||||
)
|
||||
|
||||
# 代理商-达人 关联表(多对多)
|
||||
agency_creator_association = Table(
|
||||
"agency_creator",
|
||||
Base.metadata,
|
||||
Column("agency_id", String(64), ForeignKey("agencies.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("creator_id", String(64), ForeignKey("creators.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=datetime.utcnow),
|
||||
Column("is_active", Boolean, default=True),
|
||||
)
|
||||
|
||||
|
||||
class Brand(Base, TimestampMixin):
|
||||
"""品牌方表(即租户)"""
|
||||
__tablename__ = "brands"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True) # 格式: BR123456
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 品牌信息
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 联系信息
|
||||
contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# 设置
|
||||
final_review_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # 终审开关
|
||||
|
||||
# 状态
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联
|
||||
user: Mapped["User"] = relationship("User", back_populates="brand")
|
||||
agencies: Mapped[list["Agency"]] = relationship(
|
||||
"Agency",
|
||||
secondary=brand_agency_association,
|
||||
back_populates="brands",
|
||||
)
|
||||
projects: Mapped[list["Project"]] = relationship(
|
||||
"Project",
|
||||
back_populates="brand",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Brand(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
class Agency(Base, TimestampMixin):
|
||||
"""代理商表"""
|
||||
__tablename__ = "agencies"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True) # 格式: AG123456
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 代理商信息
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 联系信息
|
||||
contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# 权限设置(可被品牌方覆盖)
|
||||
force_pass_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # 强制通过权
|
||||
|
||||
# 状态
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联
|
||||
user: Mapped["User"] = relationship("User", back_populates="agency")
|
||||
brands: Mapped[list["Brand"]] = relationship(
|
||||
"Brand",
|
||||
secondary=brand_agency_association,
|
||||
back_populates="agencies",
|
||||
)
|
||||
creators: Mapped[list["Creator"]] = relationship(
|
||||
"Creator",
|
||||
secondary=agency_creator_association,
|
||||
back_populates="agencies",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Agency(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
class Creator(Base, TimestampMixin):
|
||||
"""达人表"""
|
||||
__tablename__ = "creators"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True) # 格式: CR123456
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 达人信息
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
avatar: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 社交账号
|
||||
douyin_account: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
xiaohongshu_account: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
bilibili_account: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# 状态
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联
|
||||
user: Mapped["User"] = relationship("User", back_populates="creator")
|
||||
agencies: Mapped[list["Agency"]] = relationship(
|
||||
"Agency",
|
||||
secondary=agency_creator_association,
|
||||
back_populates="creators",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Creator(id={self.id}, name={self.name})>"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
项目模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, ForeignKey, DateTime, Table, Column, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.organization import Brand, Agency
|
||||
from app.models.task import Task
|
||||
from app.models.brief import Brief
|
||||
|
||||
|
||||
# 项目-代理商 关联表(一个项目可以分配给多个代理商)
|
||||
project_agency_association = Table(
|
||||
"project_agency",
|
||||
Base.metadata,
|
||||
Column("project_id", String(64), ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("agency_id", String(64), ForeignKey("agencies.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("created_at", DateTime(timezone=True), default=datetime.utcnow),
|
||||
Column("is_active", Boolean, default=True),
|
||||
)
|
||||
|
||||
|
||||
class Project(Base, TimestampMixin):
|
||||
"""项目表"""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
brand_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("brands.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 项目信息
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 时间
|
||||
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),
|
||||
default="active", # active, completed, archived
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
brand: Mapped["Brand"] = relationship("Brand", back_populates="projects")
|
||||
agencies: Mapped[list["Agency"]] = relationship(
|
||||
"Agency",
|
||||
secondary=project_agency_association,
|
||||
backref="projects",
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
"Task",
|
||||
back_populates="project",
|
||||
)
|
||||
brief: Mapped[Optional["Brief"]] = relationship(
|
||||
"Brief",
|
||||
back_populates="project",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Project(id={self.id}, name={self.name})>"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
审核任务模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Float, Text, ForeignKey, DateTime, Enum as SQLEnum
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Platform(str, enum.Enum):
|
||||
"""投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class ReviewTask(Base, TimestampMixin):
|
||||
"""审核任务表 (AI 自动审核)"""
|
||||
__tablename__ = "review_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 视频信息
|
||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
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)
|
||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
# 审核状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
current_step: Mapped[str] = mapped_column(String(100), default="等待处理", nullable=False)
|
||||
|
||||
# 审核结果
|
||||
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 违规详情 (JSON 数组)
|
||||
# [{"type": "forbidden_word", "content": "最好", "severity": "high", ...}]
|
||||
violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 软性风控提示 (JSON 数组)
|
||||
soft_warnings: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 审核要求 (JSON)
|
||||
requirements: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 竞品列表
|
||||
competitors: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks")
|
||||
def __repr__(self) -> str:
|
||||
return f"<ReviewTask(id={self.id}, status={self.status})>"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
规则模型
|
||||
违禁词、白名单、竞品、平台规则
|
||||
"""
|
||||
import enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class RuleStatus(str, enum.Enum):
|
||||
"""平台规则状态"""
|
||||
DRAFT = "draft" # AI 解析完成,待确认
|
||||
ACTIVE = "active" # 品牌方已确认,生效中
|
||||
INACTIVE = "inactive" # 已停用
|
||||
|
||||
|
||||
class ForbiddenWord(Base, TimestampMixin):
|
||||
"""违禁词表"""
|
||||
__tablename__ = "forbidden_words"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="forbidden_words")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ForbiddenWord(word={self.word}, category={self.category})>"
|
||||
|
||||
|
||||
class WhitelistItem(Base, TimestampMixin):
|
||||
"""白名单表"""
|
||||
__tablename__ = "whitelist_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
term: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="whitelist_items")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhitelistItem(term={self.term}, brand_id={self.brand_id})>"
|
||||
|
||||
|
||||
class Competitor(Base, TimestampMixin):
|
||||
"""竞品表"""
|
||||
__tablename__ = "competitors"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 关键词列表 (JSON 数组)
|
||||
keywords: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="competitors")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
|
||||
|
||||
|
||||
class PlatformRule(Base, TimestampMixin):
|
||||
"""平台规则表 — 品牌方上传文档 + AI 解析"""
|
||||
__tablename__ = "platform_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
|
||||
# 文档信息
|
||||
document_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
document_name: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
|
||||
# AI 解析结果(JSON)
|
||||
parsed_rules: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 状态
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=RuleStatus.DRAFT.value, index=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="platform_rules")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PlatformRule(id={self.id}, platform={self.platform}, status={self.status})>"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
任务模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Text, ForeignKey, DateTime, Enum as SQLEnum, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.types import JSONType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.project import Project
|
||||
from app.models.organization import Agency, Creator
|
||||
|
||||
|
||||
class TaskStage(str, enum.Enum):
|
||||
"""任务阶段"""
|
||||
SCRIPT_UPLOAD = "script_upload" # 待上传脚本
|
||||
SCRIPT_AI_REVIEW = "script_ai_review" # 脚本 AI 审核中
|
||||
SCRIPT_AGENCY_REVIEW = "script_agency_review" # 脚本代理商审核中
|
||||
SCRIPT_BRAND_REVIEW = "script_brand_review" # 脚本品牌方终审中
|
||||
VIDEO_UPLOAD = "video_upload" # 待上传视频
|
||||
VIDEO_AI_REVIEW = "video_ai_review" # 视频 AI 审核中
|
||||
VIDEO_AGENCY_REVIEW = "video_agency_review" # 视频代理商审核中
|
||||
VIDEO_BRAND_REVIEW = "video_brand_review" # 视频品牌方终审中
|
||||
COMPLETED = "completed" # 已完成
|
||||
REJECTED = "rejected" # 已驳回
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending" # 待处理
|
||||
PROCESSING = "processing" # 处理中
|
||||
PASSED = "passed" # 通过
|
||||
REJECTED = "rejected" # 驳回
|
||||
FORCE_PASSED = "force_passed" # 强制通过
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
"""任务表"""
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
|
||||
# 关联
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
agency_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("agencies.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
creator_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("creators.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 任务信息
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False) # 如 "宣传任务(1)"
|
||||
sequence: Mapped[int] = mapped_column(Integer, default=1, nullable=False) # 序号
|
||||
|
||||
# 当前阶段
|
||||
stage: Mapped[TaskStage] = mapped_column(
|
||||
SQLEnum(TaskStage, name="task_stage_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStage.SCRIPT_UPLOAD,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ===== 脚本相关 =====
|
||||
script_file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
script_file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
script_uploaded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 脚本 AI 审核结果
|
||||
script_ai_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
script_ai_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
script_ai_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 脚本代理商审核
|
||||
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
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)
|
||||
script_agency_reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
script_agency_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 脚本品牌方终审
|
||||
script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
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)
|
||||
script_brand_reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
script_brand_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# ===== 视频相关 =====
|
||||
video_file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
video_file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
video_duration: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 秒
|
||||
video_thumbnail_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
video_uploaded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 视频 AI 审核结果
|
||||
video_ai_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
video_ai_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
video_ai_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 视频代理商审核
|
||||
video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
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)
|
||||
video_agency_reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
video_agency_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 视频品牌方终审
|
||||
video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||
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)
|
||||
video_brand_reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
video_brand_reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# ===== 申诉相关 =====
|
||||
appeal_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) # 剩余申诉次数
|
||||
is_appeal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 是否为申诉
|
||||
appeal_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 申诉理由
|
||||
|
||||
# 关联
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="tasks")
|
||||
agency: Mapped["Agency"] = relationship("Agency", foreign_keys=[agency_id])
|
||||
creator: Mapped["Creator"] = relationship("Creator", foreign_keys=[creator_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task(id={self.id}, name={self.name}, stage={self.stage})>"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
租户模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor, PlatformRule
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""租户表"""
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联关系
|
||||
ai_config: Mapped["AIConfig"] = relationship(
|
||||
"AIConfig",
|
||||
back_populates="tenant",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
)
|
||||
review_tasks: Mapped[list["ReviewTask"]] = relationship(
|
||||
"ReviewTask",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
forbidden_words: Mapped[list["ForbiddenWord"]] = relationship(
|
||||
"ForbiddenWord",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
whitelist_items: Mapped[list["WhitelistItem"]] = relationship(
|
||||
"WhitelistItem",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
competitors: Mapped[list["Competitor"]] = relationship(
|
||||
"Competitor",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
platform_rules: Mapped[list["PlatformRule"]] = relationship(
|
||||
"PlatformRule",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Shared SQLAlchemy column types with cross-database compatibility."""
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# Use JSONB on PostgreSQL, fall back to JSON on other databases (e.g., SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
用户模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime, Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""用户角色"""
|
||||
BRAND = "brand" # 品牌方
|
||||
AGENCY = "agency" # 代理商
|
||||
CREATOR = "creator" # 达人
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
|
||||
# 登录凭证(邮箱和手机号都可以登录)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), unique=True, nullable=True, index=True)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(20), unique=True, nullable=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# 用户信息
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
avatar: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 角色
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 状态
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# 最后登录
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Refresh Token(用于 JWT 刷新)
|
||||
refresh_token: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
refresh_token_expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 关联的组织(根据角色不同,关联到不同的组织)
|
||||
brand: Mapped[Optional["Brand"]] = relationship(
|
||||
"Brand",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
)
|
||||
agency: Mapped[Optional["Agency"]] = relationship(
|
||||
"Agency",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
)
|
||||
creator: Mapped[Optional["Creator"]] = relationship(
|
||||
"Creator",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email={self.email}, role={self.role})>"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
AI 服务配置相关的 Pydantic 模型
|
||||
"""
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AIProvider(str, Enum):
|
||||
"""支持的 AI 提供商"""
|
||||
# 中转服务
|
||||
ONEAPI = "oneapi"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
# 直连厂商 - 国际
|
||||
ANTHROPIC = "anthropic"
|
||||
OPENAI = "openai"
|
||||
|
||||
# 直连厂商 - 国内
|
||||
DEEPSEEK = "deepseek"
|
||||
QWEN = "qwen"
|
||||
DOUBAO = "doubao"
|
||||
ZHIPU = "zhipu"
|
||||
MOONSHOT = "moonshot"
|
||||
|
||||
|
||||
# 提供商默认 Base URL
|
||||
PROVIDER_DEFAULT_URLS = {
|
||||
AIProvider.ANTHROPIC: "https://api.anthropic.com/v1",
|
||||
AIProvider.OPENAI: "https://api.openai.com/v1",
|
||||
AIProvider.DEEPSEEK: "https://api.deepseek.com/v1",
|
||||
AIProvider.QWEN: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
AIProvider.DOUBAO: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
AIProvider.ZHIPU: "https://open.bigmodel.cn/api/paas/v4",
|
||||
AIProvider.MOONSHOT: "https://api.moonshot.cn/v1",
|
||||
}
|
||||
|
||||
|
||||
class ModelCapability(str, Enum):
|
||||
"""模型能力类型"""
|
||||
TEXT = "text"
|
||||
VISION = "vision"
|
||||
AUDIO = "audio"
|
||||
|
||||
|
||||
# ==================== 请求模型 ====================
|
||||
|
||||
class AIModelsConfig(BaseModel):
|
||||
"""三个模型配置"""
|
||||
text: str = Field(..., description="文字处理模型")
|
||||
vision: str = Field(..., description="视频分析模型")
|
||||
audio: str = Field(..., description="音频解析模型")
|
||||
|
||||
|
||||
class AIParametersConfig(BaseModel):
|
||||
"""参数配置"""
|
||||
temperature: float = Field(default=0.7, ge=0, le=1)
|
||||
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
||||
|
||||
|
||||
class AIConfigUpdate(BaseModel):
|
||||
"""更新 AI 配置请求"""
|
||||
provider: AIProvider
|
||||
base_url: str = Field(..., min_length=1)
|
||||
api_key: str = Field(..., min_length=1)
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig = Field(default_factory=AIParametersConfig)
|
||||
|
||||
|
||||
class GetModelsRequest(BaseModel):
|
||||
"""获取模型列表请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""测试连接请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
models: AIModelsConfig
|
||||
|
||||
|
||||
# ==================== 响应模型 ====================
|
||||
|
||||
class AIConfigResponse(BaseModel):
|
||||
"""AI 配置响应"""
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key_masked: str = Field(..., description="脱敏后的 API Key")
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig
|
||||
available_models: dict[str, list[dict]] = Field(default_factory=dict)
|
||||
is_configured: bool
|
||||
last_test_at: Optional[str] = None
|
||||
last_test_result: Optional[dict] = None
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""模型信息"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ModelsListResponse(BaseModel):
|
||||
"""模型列表响应"""
|
||||
success: bool
|
||||
models: dict[str, list[ModelInfo]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ModelTestResult(BaseModel):
|
||||
"""单个模型测试结果"""
|
||||
success: bool
|
||||
latency_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
model: str
|
||||
|
||||
|
||||
class ConnectionTestResponse(BaseModel):
|
||||
"""测试连接响应"""
|
||||
success: bool
|
||||
results: dict[str, ModelTestResult]
|
||||
message: str
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""API Key 脱敏"""
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
return f"{api_key[:4]}****{api_key[-4:]}"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
认证相关 Schema
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class SendEmailCodeRequest(BaseModel):
|
||||
"""发送邮箱验证码请求"""
|
||||
email: EmailStr
|
||||
purpose: str = Field("register", pattern=r"^(register|login|reset_password)$")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"purpose": "register"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""注册请求"""
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
role: UserRole
|
||||
email_code: str = Field(..., min_length=4, max_length=8, description="邮箱验证码")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"name": "张三",
|
||||
"role": "creator",
|
||||
"email_code": "123456"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登录请求(支持邮箱+密码 或 邮箱+验证码)"""
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
email_code: Optional[str] = None # 邮箱验证码
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""刷新 Token 请求"""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class SendSmsCodeRequest(BaseModel):
|
||||
"""发送短信验证码请求"""
|
||||
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
|
||||
|
||||
|
||||
class BindPhoneRequest(BaseModel):
|
||||
"""绑定手机号请求"""
|
||||
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$")
|
||||
sms_code: str
|
||||
|
||||
|
||||
class BindEmailRequest(BaseModel):
|
||||
"""绑定邮箱请求"""
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""重置密码请求(通过邮箱验证码)"""
|
||||
email: EmailStr
|
||||
email_code: str = Field(..., min_length=4, max_length=8)
|
||||
new_password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"email_code": "123456",
|
||||
"new_password": "newpassword123"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""修改密码请求"""
|
||||
old_password: str
|
||||
new_password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户信息响应"""
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
name: str
|
||||
avatar: Optional[str] = None
|
||||
role: UserRole
|
||||
is_verified: bool
|
||||
|
||||
# 根据角色返回对应的组织 ID
|
||||
brand_id: Optional[str] = None
|
||||
agency_id: Optional[str] = None
|
||||
creator_id: Optional[str] = None
|
||||
|
||||
# 当前所属租户(品牌方)- 用于数据隔离
|
||||
tenant_id: Optional[str] = None
|
||||
tenant_name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token 响应"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 900 # 15 分钟 = 900 秒
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""登录响应"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 900
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshTokenResponse(BaseModel):
|
||||
"""刷新 Token 响应"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 900
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
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
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class BriefCreateRequest(BaseModel):
|
||||
"""创建/更新 Brief 请求"""
|
||||
file_url: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
competitors: Optional[List[str]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
min_duration: Optional[int] = None
|
||||
max_duration: Optional[int] = None
|
||||
other_requirements: Optional[str] = None
|
||||
attachments: Optional[List[dict]] = None
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
|
||||
|
||||
class BriefUpdateRequest(BaseModel):
|
||||
"""更新 Brief 请求"""
|
||||
file_url: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
competitors: Optional[List[str]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
min_duration: Optional[int] = None
|
||||
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 请求(允许更新代理商附件 + 卖点 + 违禁词 + 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
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class BriefResponse(BaseModel):
|
||||
"""Brief 响应"""
|
||||
id: str
|
||||
project_id: str
|
||||
project_name: Optional[str] = None
|
||||
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
|
||||
min_duration: Optional[int] = None
|
||||
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
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
消息相关 Schema
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
content: str
|
||||
is_read: bool
|
||||
related_task_id: Optional[str] = None
|
||||
related_project_id: Optional[str] = None
|
||||
sender_name: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
items: List[MessageResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
组织关系相关 Schema
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ===== 通用 =====
|
||||
|
||||
class BrandSummary(BaseModel):
|
||||
"""品牌方摘要"""
|
||||
id: str
|
||||
name: str
|
||||
logo: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgencySummary(BaseModel):
|
||||
"""代理商摘要"""
|
||||
id: str
|
||||
name: str
|
||||
logo: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
force_pass_enabled: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreatorSummary(BaseModel):
|
||||
"""达人摘要"""
|
||||
id: str
|
||||
name: str
|
||||
avatar: Optional[str] = None
|
||||
douyin_account: Optional[str] = None
|
||||
xiaohongshu_account: Optional[str] = None
|
||||
bilibili_account: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class InviteAgencyRequest(BaseModel):
|
||||
"""邀请代理商"""
|
||||
agency_id: str
|
||||
|
||||
|
||||
class InviteCreatorRequest(BaseModel):
|
||||
"""邀请达人"""
|
||||
creator_id: str
|
||||
|
||||
|
||||
class UpdateAgencyPermissionRequest(BaseModel):
|
||||
"""更新代理商权限"""
|
||||
force_pass_enabled: bool
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class OrganizationListResponse(BaseModel):
|
||||
"""组织列表通用响应"""
|
||||
items: list
|
||||
total: int
|
||||
|
||||
|
||||
class BrandListResponse(BaseModel):
|
||||
"""品牌方列表"""
|
||||
items: List[BrandSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AgencyListResponse(BaseModel):
|
||||
"""代理商列表"""
|
||||
items: List[AgencySummary]
|
||||
total: int
|
||||
|
||||
|
||||
class CreatorListResponse(BaseModel):
|
||||
"""达人列表"""
|
||||
items: List[CreatorSummary]
|
||||
total: int
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
用户资料相关 Schema
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ===== 角色附加信息 =====
|
||||
|
||||
class BrandProfile(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
logo: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class AgencyProfile(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
logo: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
contact_name: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class CreatorProfile(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
avatar: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
douyin_account: Optional[str] = None
|
||||
xiaohongshu_account: Optional[str] = None
|
||||
bilibili_account: Optional[str] = None
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
name: str
|
||||
avatar: Optional[str] = None
|
||||
role: str
|
||||
is_verified: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
brand: Optional[BrandProfile] = None
|
||||
agency: Optional[AgencyProfile] = None
|
||||
creator: Optional[CreatorProfile] = None
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
avatar: Optional[str] = Field(None, max_length=2048)
|
||||
phone: Optional[str] = Field(None, max_length=20)
|
||||
# 品牌方/代理商字段
|
||||
description: Optional[str] = None
|
||||
contact_name: Optional[str] = Field(None, max_length=100)
|
||||
contact_phone: Optional[str] = Field(None, max_length=20)
|
||||
contact_email: Optional[str] = Field(None, max_length=255)
|
||||
# 达人字段
|
||||
bio: Optional[str] = None
|
||||
douyin_account: Optional[str] = Field(None, max_length=100)
|
||||
xiaohongshu_account: Optional[str] = Field(None, max_length=100)
|
||||
bilibili_account: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(..., min_length=6)
|
||||
new_password: str = Field(..., min_length=6)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
项目相关 Schema
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
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 列表
|
||||
|
||||
|
||||
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)$")
|
||||
|
||||
|
||||
class ProjectAssignAgencyRequest(BaseModel):
|
||||
"""分配代理商到项目"""
|
||||
agency_ids: List[str]
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class AgencySummary(BaseModel):
|
||||
"""代理商摘要"""
|
||||
id: str
|
||||
name: str
|
||||
logo: Optional[str] = None
|
||||
|
||||
|
||||
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
|
||||
start_date: Optional[datetime] = None
|
||||
deadline: Optional[datetime] = None
|
||||
agencies: List[AgencySummary] = []
|
||||
task_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
"""项目列表响应"""
|
||||
items: List[ProjectResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
审核相关的 Pydantic 模型(API 契约定义)
|
||||
所有测试和实现必须遵循此契约
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ==================== 枚举定义 ====================
|
||||
|
||||
class Platform(str, Enum):
|
||||
"""支持的投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
"""风险等级"""
|
||||
HIGH = "high" # 法律违规(广告法极限词)
|
||||
MEDIUM = "medium" # 平台规则违规
|
||||
LOW = "low" # 品牌规范违规
|
||||
|
||||
|
||||
class ViolationType(str, Enum):
|
||||
"""违规类型"""
|
||||
FORBIDDEN_WORD = "forbidden_word" # 违禁词
|
||||
EFFICACY_CLAIM = "efficacy_claim" # 功效宣称
|
||||
COMPETITOR_LOGO = "competitor_logo" # 竞品露出
|
||||
DURATION_SHORT = "duration_short" # 时长不足
|
||||
MENTION_MISSING = "mention_missing" # 品牌提及不足
|
||||
BRAND_SAFETY = "brand_safety" # 品牌安全风险
|
||||
|
||||
|
||||
class ViolationSource(str, Enum):
|
||||
"""违规来源"""
|
||||
TEXT = "text" # 文本/脚本
|
||||
SPEECH = "speech" # 语音(ASR)
|
||||
SUBTITLE = "subtitle" # 字幕(OCR)
|
||||
VISUAL = "visual" # 画面(CV)
|
||||
|
||||
|
||||
class SoftRiskAction(str, Enum):
|
||||
"""软性风控动作"""
|
||||
CONFIRM = "confirm" # 需要二次确认
|
||||
NOTE = "note" # 需要填写备注
|
||||
|
||||
|
||||
class SoftRiskWarning(BaseModel):
|
||||
"""软性风控提示(Warn-only)"""
|
||||
code: str = Field(..., description="提示类型代码")
|
||||
message: str = Field(..., description="提示内容")
|
||||
action_required: SoftRiskAction = Field(..., description="要求动作")
|
||||
blocking: bool = Field(default=False, description="是否阻断(默认不阻断)")
|
||||
context: Optional[dict] = Field(None, description="附加上下文")
|
||||
|
||||
|
||||
class SoftRiskContext(BaseModel):
|
||||
"""软性风控输入上下文"""
|
||||
violation_rate: Optional[float] = Field(None, ge=0, le=1, description="违规率")
|
||||
violation_threshold: Optional[float] = Field(None, ge=0, le=1, description="违规率阈值")
|
||||
asr_confidence: Optional[float] = Field(None, ge=0, le=1, description="ASR 置信度")
|
||||
ocr_confidence: Optional[float] = Field(None, ge=0, le=1, description="OCR 置信度")
|
||||
has_history_violation: Optional[bool] = Field(None, description="是否有历史类似违规")
|
||||
|
||||
|
||||
# ==================== 通用模型 ====================
|
||||
|
||||
class Position(BaseModel):
|
||||
"""文本位置"""
|
||||
start: int = Field(..., description="起始位置")
|
||||
end: int = Field(..., description="结束位置")
|
||||
|
||||
|
||||
class Violation(BaseModel):
|
||||
"""违规项(统一结构)"""
|
||||
type: ViolationType = Field(..., description="违规类型")
|
||||
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="文本位置(脚本审核)")
|
||||
|
||||
# 视频审核字段
|
||||
timestamp: Optional[float] = Field(None, description="开始时间戳(秒)")
|
||||
timestamp_end: Optional[float] = Field(None, description="结束时间戳(秒)")
|
||||
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):
|
||||
"""脚本预审请求"""
|
||||
content: str = Field(..., min_length=1, description="脚本内容")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
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):
|
||||
"""
|
||||
脚本预审响应
|
||||
|
||||
结构:
|
||||
- score: 加权总分(向后兼容)
|
||||
- summary: 整体摘要
|
||||
- dimensions: 四维度评分(法规/平台/品牌安全/Brief匹配)
|
||||
- selling_point_matches: 卖点匹配详情
|
||||
- violations: 违规项列表,每项带 dimension 标签
|
||||
- missing_points: 遗漏的核心卖点(向后兼容)
|
||||
"""
|
||||
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="遗漏的核心卖点")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
ai_available: bool = Field(True, description="AI 服务是否可用(False 表示降级为纯关键词检测)")
|
||||
|
||||
|
||||
# ==================== 视频审核 ====================
|
||||
|
||||
class VideoReviewRequest(BaseModel):
|
||||
"""视频审核请求"""
|
||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
competitors: Optional[list[str]] = Field(None, description="竞品列表")
|
||||
requirements: Optional[dict] = Field(None, description="审核要求(时长、频次等)")
|
||||
|
||||
|
||||
class VideoReviewSubmitResponse(BaseModel):
|
||||
"""视频审核提交响应(202 Accepted)"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.PENDING, description="任务状态")
|
||||
|
||||
|
||||
class VideoReviewProgressResponse(BaseModel):
|
||||
"""视频审核进度响应"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(..., description="任务状态")
|
||||
progress: int = Field(..., ge=0, le=100, description="进度百分比")
|
||||
current_step: str = Field(..., description="当前处理步骤")
|
||||
|
||||
|
||||
class VideoReviewResultResponse(BaseModel):
|
||||
"""
|
||||
视频审核结果响应(200 OK)
|
||||
|
||||
结构与脚本审核一致:
|
||||
- score: 合规分数
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 timestamp 和 suggestion
|
||||
"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.COMPLETED, description="任务状态")
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
平台规则相关 Schema
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlatformRuleParseRequest(BaseModel):
|
||||
"""上传文档并解析"""
|
||||
document_url: str = Field(..., description="TOS 上传后的文件 URL")
|
||||
document_name: str = Field(..., description="原始文件名(用于判断格式)")
|
||||
platform: str = Field(..., description="目标平台 (douyin/xiaohongshu/bilibili/kuaishou)")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
|
||||
|
||||
class ParsedRulesData(BaseModel):
|
||||
"""AI 解析出的结构化规则"""
|
||||
forbidden_words: list[str] = Field(default_factory=list, description="违禁词列表")
|
||||
restricted_words: list[dict] = Field(
|
||||
default_factory=list,
|
||||
description="限制词 [{word, condition, suggestion}]",
|
||||
)
|
||||
duration: Optional[dict] = Field(
|
||||
None,
|
||||
description="时长要求 {min_seconds, max_seconds}",
|
||||
)
|
||||
content_requirements: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="内容要求(如'必须展示产品')",
|
||||
)
|
||||
other_rules: list[dict] = Field(
|
||||
default_factory=list,
|
||||
description="其他规则 [{rule, description}]",
|
||||
)
|
||||
|
||||
|
||||
class PlatformRuleParseResponse(BaseModel):
|
||||
"""解析响应(draft 状态)"""
|
||||
id: str
|
||||
platform: str
|
||||
brand_id: str
|
||||
document_url: str
|
||||
document_name: str
|
||||
parsed_rules: ParsedRulesData
|
||||
status: str
|
||||
|
||||
|
||||
class PlatformRuleConfirmRequest(BaseModel):
|
||||
"""确认/编辑解析结果"""
|
||||
parsed_rules: ParsedRulesData = Field(..., description="品牌方可能修改过的规则")
|
||||
|
||||
|
||||
class PlatformRuleResponse(BaseModel):
|
||||
"""完整响应"""
|
||||
id: str
|
||||
platform: str
|
||||
brand_id: str
|
||||
document_url: str
|
||||
document_name: str
|
||||
parsed_rules: ParsedRulesData
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PlatformRuleListResponse(BaseModel):
|
||||
"""列表响应"""
|
||||
items: list[PlatformRuleResponse]
|
||||
total: int
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
任务相关 Schema
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from app.models.task import TaskStage, TaskStatus
|
||||
|
||||
|
||||
# ===== 通用 =====
|
||||
|
||||
class AIReviewResult(BaseModel):
|
||||
"""AI 审核结果"""
|
||||
score: int = Field(..., ge=0, le=100)
|
||||
violations: List[dict] = []
|
||||
soft_warnings: List[dict] = []
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewAction(BaseModel):
|
||||
"""审核操作"""
|
||||
action: str = Field(..., pattern="^(pass|reject|force_pass)$")
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""创建任务请求(代理商操作)"""
|
||||
project_id: str
|
||||
creator_id: str
|
||||
name: Optional[str] = None # 不传则自动生成 "宣传任务(N)"
|
||||
|
||||
|
||||
class TaskScriptUploadRequest(BaseModel):
|
||||
"""上传脚本请求"""
|
||||
file_url: str
|
||||
file_name: str
|
||||
|
||||
|
||||
class TaskVideoUploadRequest(BaseModel):
|
||||
"""上传视频请求"""
|
||||
file_url: str
|
||||
file_name: str
|
||||
duration: Optional[int] = None # 秒
|
||||
thumbnail_url: Optional[str] = None
|
||||
|
||||
|
||||
class TaskReviewRequest(BaseModel):
|
||||
"""审核请求"""
|
||||
action: str = Field(..., pattern="^(pass|reject|force_pass)$")
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class AppealRequest(BaseModel):
|
||||
"""申诉请求"""
|
||||
reason: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AppealCountRequest(BaseModel):
|
||||
"""申请增加申诉次数请求"""
|
||||
task_id: str
|
||||
|
||||
|
||||
class AppealCountActionRequest(BaseModel):
|
||||
"""处理申诉次数请求"""
|
||||
action: str = Field(..., pattern="^(approve|reject)$")
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
|
||||
class CreatorInfo(BaseModel):
|
||||
"""达人信息"""
|
||||
id: str
|
||||
name: str
|
||||
avatar: Optional[str] = None
|
||||
|
||||
|
||||
class AgencyInfo(BaseModel):
|
||||
"""代理商信息"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ProjectInfo(BaseModel):
|
||||
"""项目信息"""
|
||||
id: str
|
||||
name: str
|
||||
brand_name: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""任务响应"""
|
||||
id: str
|
||||
name: str
|
||||
sequence: int
|
||||
stage: TaskStage
|
||||
|
||||
# 关联信息
|
||||
project: ProjectInfo
|
||||
agency: AgencyInfo
|
||||
creator: CreatorInfo
|
||||
|
||||
# 脚本信息
|
||||
script_file_url: Optional[str] = None
|
||||
script_file_name: Optional[str] = None
|
||||
script_uploaded_at: Optional[datetime] = None
|
||||
script_ai_score: Optional[int] = None
|
||||
script_ai_result: Optional[dict] = None
|
||||
script_agency_status: Optional[TaskStatus] = None
|
||||
script_agency_comment: Optional[str] = None
|
||||
script_brand_status: Optional[TaskStatus] = None
|
||||
script_brand_comment: Optional[str] = None
|
||||
|
||||
# 视频信息
|
||||
video_file_url: Optional[str] = None
|
||||
video_file_name: Optional[str] = None
|
||||
video_duration: Optional[int] = None
|
||||
video_thumbnail_url: Optional[str] = None
|
||||
video_uploaded_at: Optional[datetime] = None
|
||||
video_ai_score: Optional[int] = None
|
||||
video_ai_result: Optional[dict] = None
|
||||
video_agency_status: Optional[TaskStatus] = None
|
||||
video_agency_comment: Optional[str] = None
|
||||
video_brand_status: Optional[TaskStatus] = None
|
||||
video_brand_comment: Optional[str] = None
|
||||
|
||||
# 申诉
|
||||
appeal_count: int = 1
|
||||
is_appeal: bool = False
|
||||
appeal_reason: Optional[str] = None
|
||||
|
||||
# 时间
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
"""任务列表响应"""
|
||||
items: List[TaskResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class TaskSummary(BaseModel):
|
||||
"""任务摘要(用于列表)"""
|
||||
id: str
|
||||
name: str
|
||||
stage: TaskStage
|
||||
creator_name: str
|
||||
creator_avatar: Optional[str] = None
|
||||
project_name: str
|
||||
is_appeal: bool = False
|
||||
appeal_reason: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReviewTaskListResponse(BaseModel):
|
||||
"""待审核任务列表响应"""
|
||||
items: List[TaskSummary]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,54 @@
|
||||
"""服务层模块"""
|
||||
from typing import Optional, Any
|
||||
|
||||
_openai_import_error: Optional[Exception] = None
|
||||
|
||||
try:
|
||||
from app.services.ai_client import OpenAICompatibleClient, AIResponse, ConnectionTestResult
|
||||
from app.services.ai_service import AIServiceFactory, get_ai_client_for_tenant
|
||||
except ModuleNotFoundError as exc: # openai 依赖缺失时允许非 AI 路径正常导入
|
||||
_openai_import_error = exc
|
||||
OpenAICompatibleClient = None
|
||||
AIResponse = None
|
||||
ConnectionTestResult = None
|
||||
AIServiceFactory = None
|
||||
|
||||
def get_ai_client_for_tenant(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise ModuleNotFoundError(
|
||||
"Optional dependency 'openai' is required for AI client usage."
|
||||
) from _openai_import_error
|
||||
|
||||
# 视频处理服务(无外部依赖)
|
||||
from app.services.video_download import VideoDownloadService, DownloadResult, get_download_service
|
||||
from app.services.keyframe import KeyFrameExtractor, KeyFrame, ExtractionResult, get_keyframe_extractor
|
||||
from app.services.asr import ASRService, VideoASRService, TranscriptionResult
|
||||
from app.services.vision import VisionAnalysisService, CompetitorLogoDetector, VideoOCRService
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
__all__ = [
|
||||
# AI 客户端
|
||||
"OpenAICompatibleClient",
|
||||
"AIResponse",
|
||||
"ConnectionTestResult",
|
||||
"AIServiceFactory",
|
||||
"get_ai_client_for_tenant",
|
||||
# 视频下载
|
||||
"VideoDownloadService",
|
||||
"DownloadResult",
|
||||
"get_download_service",
|
||||
# 关键帧提取
|
||||
"KeyFrameExtractor",
|
||||
"KeyFrame",
|
||||
"ExtractionResult",
|
||||
"get_keyframe_extractor",
|
||||
# ASR
|
||||
"ASRService",
|
||||
"VideoASRService",
|
||||
"TranscriptionResult",
|
||||
# 视觉分析
|
||||
"VisionAnalysisService",
|
||||
"CompetitorLogoDetector",
|
||||
"VideoOCRService",
|
||||
# 视频审核
|
||||
"VideoReviewService",
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
OpenAI 兼容 AI 客户端
|
||||
支持多种 AI 提供商的统一接口
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.schemas.ai_config import AIProvider, ModelCapability
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIResponse:
|
||||
"""AI 响应"""
|
||||
content: str
|
||||
model: str
|
||||
usage: dict
|
||||
finish_reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionTestResult:
|
||||
"""连接测试结果"""
|
||||
success: bool
|
||||
latency_ms: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class OpenAICompatibleClient:
|
||||
"""
|
||||
OpenAI 兼容 API 客户端
|
||||
|
||||
支持:
|
||||
- OpenAI
|
||||
- Azure OpenAI
|
||||
- Anthropic (通过 OpenAI 兼容层)
|
||||
- DeepSeek
|
||||
- Qwen (通义千问)
|
||||
- Doubao (豆包)
|
||||
- 各种中转服务 (OneAPI, OpenRouter)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider: str = "openai",
|
||||
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
|
||||
|
||||
# 创建 OpenAI 客户端
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
**kwargs,
|
||||
) -> AIResponse:
|
||||
"""
|
||||
聊天补全
|
||||
|
||||
Args:
|
||||
messages: 消息列表 [{"role": "user", "content": "..."}]
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
|
||||
Returns:
|
||||
AIResponse 包含生成的内容
|
||||
"""
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
return AIResponse(
|
||||
content=choice.message.content or "",
|
||||
model=response.model,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
||||
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
||||
"total_tokens": response.usage.total_tokens if response.usage else 0,
|
||||
},
|
||||
finish_reason=choice.finish_reason or "stop",
|
||||
)
|
||||
|
||||
async def vision_analysis(
|
||||
self,
|
||||
image_urls: list[str],
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 2000,
|
||||
) -> AIResponse:
|
||||
"""
|
||||
视觉分析(图像理解)
|
||||
|
||||
Args:
|
||||
image_urls: 图像 URL 列表
|
||||
prompt: 分析提示
|
||||
model: 视觉模型名称
|
||||
|
||||
Returns:
|
||||
AIResponse 包含分析结果
|
||||
"""
|
||||
# 构建多模态消息
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
|
||||
for url in image_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
|
||||
messages = [{"role": "user", "content": content}]
|
||||
|
||||
return await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
async def audio_transcription(
|
||||
self,
|
||||
audio_url: str,
|
||||
model: str = "whisper-1",
|
||||
language: str = "zh",
|
||||
) -> AIResponse:
|
||||
"""
|
||||
音频转写 (ASR)
|
||||
|
||||
Args:
|
||||
audio_url: 音频文件 URL
|
||||
model: 转写模型
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
AIResponse 包含转写文本
|
||||
"""
|
||||
# 下载音频文件
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(audio_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
audio_data = response.content
|
||||
|
||||
# 调用 Whisper API
|
||||
transcription = await self.client.audio.transcriptions.create(
|
||||
model=model,
|
||||
file=("audio.mp3", audio_data, "audio/mpeg"),
|
||||
language=language,
|
||||
)
|
||||
|
||||
return AIResponse(
|
||||
content=transcription.text,
|
||||
model=model,
|
||||
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
async def test_connection(
|
||||
self,
|
||||
model: str,
|
||||
capability: ModelCapability = ModelCapability.TEXT,
|
||||
) -> ConnectionTestResult:
|
||||
"""
|
||||
测试模型连接
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
capability: 模型能力类型
|
||||
|
||||
Returns:
|
||||
ConnectionTestResult 包含测试结果
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if capability == ModelCapability.AUDIO:
|
||||
# 音频模型无法简单测试,只验证 API 可达
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(success=True, latency_ms=latency_ms)
|
||||
|
||||
elif capability == ModelCapability.VISION:
|
||||
# 视觉模型测试:发送简单的文本请求
|
||||
response = await self.chat_completion(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
model=model,
|
||||
max_tokens=5,
|
||||
)
|
||||
|
||||
else:
|
||||
# 文本模型测试
|
||||
response = await self.chat_completion(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
model=model,
|
||||
max_tokens=5,
|
||||
)
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(success=True, latency_ms=latency_ms)
|
||||
|
||||
except Exception as e:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
latency_ms=latency_ms,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def list_models(self) -> dict[str, list[dict]]:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
Returns:
|
||||
按能力分类的模型列表
|
||||
{"text": [...], "vision": [...], "audio": [...]}
|
||||
"""
|
||||
try:
|
||||
models = await self.client.models.list()
|
||||
|
||||
# 已知模型能力映射
|
||||
known_capabilities = {
|
||||
# OpenAI
|
||||
"gpt-4o": ["text", "vision"],
|
||||
"gpt-4o-mini": ["text", "vision"],
|
||||
"gpt-4-turbo": ["text", "vision"],
|
||||
"gpt-4": ["text"],
|
||||
"gpt-3.5-turbo": ["text"],
|
||||
"whisper-1": ["audio"],
|
||||
|
||||
# Claude (通过兼容层)
|
||||
"claude-3-opus": ["text", "vision"],
|
||||
"claude-3-sonnet": ["text", "vision"],
|
||||
"claude-3-haiku": ["text", "vision"],
|
||||
|
||||
# DeepSeek
|
||||
"deepseek-chat": ["text"],
|
||||
"deepseek-coder": ["text"],
|
||||
|
||||
# Qwen
|
||||
"qwen-turbo": ["text"],
|
||||
"qwen-plus": ["text"],
|
||||
"qwen-max": ["text"],
|
||||
"qwen-vl-plus": ["vision"],
|
||||
"qwen-vl-max": ["vision"],
|
||||
|
||||
# Doubao
|
||||
"doubao-pro": ["text"],
|
||||
"doubao-lite": ["text"],
|
||||
}
|
||||
|
||||
result: dict[str, list[dict]] = {
|
||||
"text": [],
|
||||
"vision": [],
|
||||
"audio": [],
|
||||
}
|
||||
|
||||
for model in models.data:
|
||||
model_id = model.id
|
||||
capabilities = known_capabilities.get(model_id, ["text"])
|
||||
|
||||
for cap in capabilities:
|
||||
if cap in result:
|
||||
result[cap].append({
|
||||
"id": model_id,
|
||||
"name": model_id.replace("-", " ").title(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
# 如果无法获取模型列表,返回预设列表
|
||||
return {
|
||||
"text": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "gpt-4o-mini", "name": "GPT-4o Mini"},
|
||||
{"id": "deepseek-chat", "name": "DeepSeek Chat"},
|
||||
],
|
||||
"vision": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "qwen-vl-max", "name": "Qwen VL Max"},
|
||||
],
|
||||
"audio": [
|
||||
{"id": "whisper-1", "name": "Whisper"},
|
||||
],
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception:
|
||||
# 关闭失败不应影响主流程
|
||||
pass
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def create_ai_client(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider: str = "openai",
|
||||
) -> OpenAICompatibleClient:
|
||||
"""创建 AI 客户端"""
|
||||
return OpenAICompatibleClient(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
AI 服务工厂
|
||||
根据租户配置创建和管理 AI 客户端
|
||||
"""
|
||||
from typing import Optional
|
||||
from cachetools import TTLCache
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.utils.crypto import decrypt_api_key
|
||||
|
||||
|
||||
class AIServiceFactory:
|
||||
"""
|
||||
AI 服务工厂
|
||||
|
||||
根据租户的 AI 配置创建对应的 AI 客户端
|
||||
使用 TTL 缓存避免频繁创建客户端
|
||||
"""
|
||||
|
||||
# 客户端缓存,TTL 10 分钟
|
||||
_cache: TTLCache = TTLCache(maxsize=100, ttl=600)
|
||||
|
||||
@classmethod
|
||||
async def get_client(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[OpenAICompatibleClient]:
|
||||
"""
|
||||
获取租户的 AI 客户端
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
AI 客户端实例,未配置返回 None
|
||||
"""
|
||||
# 检查缓存
|
||||
cache_key = f"ai_client:{tenant_id}"
|
||||
if cache_key in cls._cache:
|
||||
return cls._cache[cache_key]
|
||||
|
||||
# 从数据库获取配置
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
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
|
||||
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls, tenant_id: str) -> None:
|
||||
"""
|
||||
使缓存失效
|
||||
|
||||
当租户更新 AI 配置时调用
|
||||
"""
|
||||
cache_key = f"ai_client:{tenant_id}"
|
||||
if cache_key in cls._cache:
|
||||
del cls._cache[cache_key]
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls) -> None:
|
||||
"""清空所有缓存"""
|
||||
cls._cache.clear()
|
||||
|
||||
@classmethod
|
||||
async def get_config(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[AIConfig]:
|
||||
"""
|
||||
获取租户的 AI 配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
AI 配置模型,未配置返回 None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def create_or_update_config(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
api_key_encrypted: str,
|
||||
models: dict,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
db: AsyncSession,
|
||||
) -> AIConfig:
|
||||
"""
|
||||
创建或更新 AI 配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
provider: 提供商
|
||||
base_url: API 地址
|
||||
api_key_encrypted: 加密的 API Key
|
||||
models: 模型配置
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的配置
|
||||
"""
|
||||
# 查找现有配置
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
# 更新现有配置
|
||||
config.provider = provider
|
||||
config.base_url = base_url
|
||||
config.api_key_encrypted = api_key_encrypted
|
||||
config.models = models
|
||||
config.temperature = temperature
|
||||
config.max_tokens = max_tokens
|
||||
config.is_configured = True
|
||||
else:
|
||||
# 创建新配置
|
||||
config = AIConfig(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=models,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
is_configured=True,
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# 使缓存失效
|
||||
cls.invalidate_cache(tenant_id)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def get_ai_client_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[OpenAICompatibleClient]:
|
||||
"""获取租户的 AI 客户端"""
|
||||
return await AIServiceFactory.get_client(tenant_id, db)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
ASR 语音转写服务
|
||||
集成 Whisper API 实现音频转写
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
"""转写片段"""
|
||||
text: str
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptionResult:
|
||||
"""转写结果"""
|
||||
success: bool
|
||||
text: str = "" # 完整文本
|
||||
segments: list[TranscriptSegment] = field(default_factory=list)
|
||||
language: str = "zh"
|
||||
duration: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ASRService:
|
||||
"""ASR 语音转写服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "whisper-1",
|
||||
timeout: float = 300.0,
|
||||
):
|
||||
"""
|
||||
初始化 ASR 服务
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
base_url: API 基础 URL
|
||||
model: 模型名称
|
||||
timeout: 请求超时(秒)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
|
||||
async def transcribe_file(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: str = "zh",
|
||||
response_format: str = "verbose_json",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写音频文件
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径
|
||||
language: 语言代码
|
||||
response_format: 响应格式
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"文件不存在: {audio_path}",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout)
|
||||
) as client:
|
||||
with open(audio_path, "rb") as f:
|
||||
files = {"file": (os.path.basename(audio_path), f, "audio/mpeg")}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"language": language,
|
||||
"response_format": response_format,
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{self.base_url}/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"API 错误 {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
return self._parse_response(result, language)
|
||||
|
||||
except Exception as e:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def transcribe_url(
|
||||
self,
|
||||
audio_url: str,
|
||||
language: str = "zh",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写远程音频
|
||||
|
||||
Args:
|
||||
audio_url: 音频 URL
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
# 下载音频到临时文件
|
||||
temp_path = None
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(audio_url)
|
||||
if response.status_code != 200:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"下载音频失败: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# 写入临时文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".mp3",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(response.content)
|
||||
temp_path = f.name
|
||||
|
||||
# 转写
|
||||
result = await self.transcribe_file(temp_path, language)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _parse_response(
|
||||
self,
|
||||
response: dict,
|
||||
language: str,
|
||||
) -> TranscriptionResult:
|
||||
"""解析 API 响应"""
|
||||
text = response.get("text", "")
|
||||
duration = response.get("duration", 0.0)
|
||||
|
||||
segments = []
|
||||
for seg in response.get("segments", []):
|
||||
segments.append(TranscriptSegment(
|
||||
text=seg.get("text", "").strip(),
|
||||
start=seg.get("start", 0.0),
|
||||
end=seg.get("end", 0.0),
|
||||
confidence=seg.get("confidence", 1.0) if "confidence" in seg else 1.0,
|
||||
))
|
||||
|
||||
# 如果没有分段信息,创建单个分段
|
||||
if not segments and text:
|
||||
segments = [TranscriptSegment(
|
||||
text=text,
|
||||
start=0.0,
|
||||
end=duration,
|
||||
)]
|
||||
|
||||
return TranscriptionResult(
|
||||
success=True,
|
||||
text=text,
|
||||
segments=segments,
|
||||
language=language,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
class AudioExtractor:
|
||||
"""从视频中提取音频"""
|
||||
|
||||
def __init__(self, ffmpeg_path: str = "ffmpeg"):
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
|
||||
async def extract_audio(
|
||||
self,
|
||||
video_path: str,
|
||||
output_path: Optional[str] = None,
|
||||
format: str = "mp3",
|
||||
sample_rate: int = 16000,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从视频中提取音频
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出路径,默认生成临时文件
|
||||
format: 输出格式
|
||||
sample_rate: 采样率
|
||||
|
||||
Returns:
|
||||
音频文件路径,失败返回 None
|
||||
"""
|
||||
import shutil
|
||||
|
||||
if not shutil.which(self.ffmpeg_path):
|
||||
return None
|
||||
|
||||
if output_path is None:
|
||||
output_path = tempfile.mktemp(suffix=f".{format}")
|
||||
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vn", # 不要视频
|
||||
"-acodec", "libmp3lame" if format == "mp3" else "pcm_s16le",
|
||||
"-ar", str(sample_rate),
|
||||
"-ac", "1", # 单声道
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
return output_path
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class VideoASRService:
|
||||
"""视频 ASR 服务(组合音频提取和转写)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "whisper-1",
|
||||
):
|
||||
self.asr = ASRService(api_key, base_url, model)
|
||||
self.audio_extractor = AudioExtractor()
|
||||
|
||||
async def transcribe_video(
|
||||
self,
|
||||
video_path: str,
|
||||
language: str = "zh",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写视频中的语音
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
# 提取音频
|
||||
audio_path = await self.audio_extractor.extract_audio(video_path)
|
||||
if not audio_path:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error="音频提取失败,请确保 FFmpeg 已安装",
|
||||
)
|
||||
|
||||
try:
|
||||
# 转写
|
||||
result = await self.asr.transcribe_file(audio_path, language)
|
||||
return result
|
||||
finally:
|
||||
# 清理临时音频
|
||||
if os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
"""审计日志服务"""
|
||||
import json
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def log_action(
|
||||
db: AsyncSession,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
user_name: Optional[str] = None,
|
||||
user_role: Optional[str] = None,
|
||||
detail: Optional[dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
):
|
||||
"""记录审计日志"""
|
||||
log = AuditLog(
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
user_role=user_role,
|
||||
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(log)
|
||||
# Don't commit here - let the request lifecycle handle it
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
认证服务
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""哈希密码"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""生成语义化 ID"""
|
||||
# 格式: BR123456, AG123456, CR123456
|
||||
random_part = secrets.randbelow(900000) + 100000 # 100000-999999
|
||||
return f"{prefix}{random_part}"
|
||||
|
||||
|
||||
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建访问 Token"""
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str, expires_days: int = 7) -> tuple[str, datetime]:
|
||||
"""创建刷新 Token"""
|
||||
expire = datetime.utcnow() + timedelta(days=expires_days)
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
token = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return token, expire
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""解码 Token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""通过邮箱获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_phone(db: AsyncSession, phone: str) -> Optional[User]:
|
||||
"""通过手机号获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.phone == phone)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[User]:
|
||||
"""通过 ID 获取用户"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
email: Optional[str],
|
||||
phone: Optional[str],
|
||||
password: str,
|
||||
name: str,
|
||||
role: UserRole,
|
||||
is_verified: bool = False,
|
||||
) -> User:
|
||||
"""创建用户"""
|
||||
user_id = generate_id("U")
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=email,
|
||||
phone=phone,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
role=role,
|
||||
is_active=True,
|
||||
is_verified=is_verified,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# 根据角色创建对应的组织实体
|
||||
if role == UserRole.BRAND:
|
||||
brand = Brand(
|
||||
id=generate_id("BR"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(brand)
|
||||
elif role == UserRole.AGENCY:
|
||||
agency = Agency(
|
||||
id=generate_id("AG"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(agency)
|
||||
elif role == UserRole.CREATOR:
|
||||
creator = Creator(
|
||||
id=generate_id("CR"),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
)
|
||||
db.add(creator)
|
||||
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
"""验证用户登录"""
|
||||
user = None
|
||||
|
||||
if email:
|
||||
user = await get_user_by_email(db, email)
|
||||
elif phone:
|
||||
user = await get_user_by_phone(db, phone)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if password and not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_refresh_token(db: AsyncSession, user: User, refresh_token: str, expires_at: datetime) -> None:
|
||||
"""更新用户的刷新 Token"""
|
||||
user.refresh_token = refresh_token
|
||||
user.refresh_token_expires_at = expires_at
|
||||
user.last_login_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def get_user_organization_info(db: AsyncSession, user: User) -> dict:
|
||||
"""获取用户的组织信息"""
|
||||
info = {
|
||||
"brand_id": None,
|
||||
"agency_id": None,
|
||||
"creator_id": None,
|
||||
"tenant_id": None,
|
||||
"tenant_name": None,
|
||||
}
|
||||
|
||||
if user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if brand:
|
||||
info["brand_id"] = brand.id
|
||||
info["tenant_id"] = brand.id
|
||||
info["tenant_name"] = brand.name
|
||||
|
||||
elif user.role == UserRole.AGENCY:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if agency:
|
||||
info["agency_id"] = agency.id
|
||||
# 代理商可能服务多个品牌,这里暂时不设置 tenant
|
||||
|
||||
elif user.role == UserRole.CREATOR:
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.user_id == user.id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
if creator:
|
||||
info["creator_id"] = creator.id
|
||||
# 达人可能服务多个代理商,这里暂时不设置 tenant
|
||||
|
||||
return info
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
文档解析服务
|
||||
从 PDF/Word/Excel 文档中提取纯文本
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentParser:
|
||||
"""从文档中提取纯文本"""
|
||||
|
||||
@staticmethod
|
||||
async def download_and_parse(document_url: str, document_name: str) -> str:
|
||||
"""
|
||||
下载文档并解析为纯文本
|
||||
|
||||
优先使用 TOS SDK 直接下载(私有桶无需签名),
|
||||
回退到 HTTP 预签名 URL 下载。
|
||||
|
||||
Args:
|
||||
document_url: 文档 URL (TOS)
|
||||
document_name: 原始文件名(用于判断格式)
|
||||
|
||||
Returns:
|
||||
提取的纯文本
|
||||
"""
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
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)
|
||||
|
||||
# 跳过过大的文件(>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
|
||||
|
||||
# 文件解析可能很慢(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]]:
|
||||
"""
|
||||
下载文档并提取嵌入的图片,返回 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 not in ("pdf", "doc", "docx", "xls", "xlsx"):
|
||||
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=f".{ext}") as tmp:
|
||||
tmp.write(file_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
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):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_tos_sdk(document_url: str) -> Optional[bytes]:
|
||||
"""通过 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:
|
||||
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
|
||||
|
||||
return await asyncio.to_thread(_sync_download)
|
||||
|
||||
@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)
|
||||
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
|
||||
def parse_file(file_path: str, file_name: str) -> str:
|
||||
"""
|
||||
根据扩展名选择解析器,返回纯文本
|
||||
|
||||
Args:
|
||||
file_path: 本地文件路径
|
||||
file_name: 原始文件名
|
||||
|
||||
Returns:
|
||||
提取的纯文本
|
||||
"""
|
||||
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
||||
|
||||
if ext == "pdf":
|
||||
return DocumentParser._parse_pdf(file_path)
|
||||
elif ext in ("doc", "docx"):
|
||||
return DocumentParser._parse_docx(file_path)
|
||||
elif ext in ("xls", "xlsx"):
|
||||
return DocumentParser._parse_xlsx(file_path)
|
||||
elif ext == "txt":
|
||||
return DocumentParser._parse_txt(file_path)
|
||||
else:
|
||||
raise ValueError(f"不支持的文件格式: {ext}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_pdf(path: str) -> str:
|
||||
"""PyMuPDF 提取 PDF 文本,回退 pdfplumber"""
|
||||
import fitz
|
||||
|
||||
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:
|
||||
"""python-docx 提取 Word 文本"""
|
||||
from docx import Document
|
||||
|
||||
doc = Document(path)
|
||||
texts = []
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
texts.append(para.text)
|
||||
# 也提取表格内容
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = "\t".join(cell.text.strip() for cell in row.cells if cell.text.strip())
|
||||
if row_text:
|
||||
texts.append(row_text)
|
||||
return "\n".join(texts)
|
||||
|
||||
@staticmethod
|
||||
def _parse_xlsx(path: str) -> str:
|
||||
"""openpyxl 提取 Excel 文本(所有 sheet 拼接)"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
texts = []
|
||||
for sheet in wb.worksheets:
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
row_text = "\t".join(str(cell) for cell in row if cell is not None)
|
||||
if row_text.strip():
|
||||
texts.append(row_text)
|
||||
wb.close()
|
||||
return "\n".join(texts)
|
||||
|
||||
@staticmethod
|
||||
def _parse_txt(path: str) -> str:
|
||||
"""纯文本文件"""
|
||||
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
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
邮件发送服务
|
||||
|
||||
开发环境:将验证码输出到控制台(不实际发送)。
|
||||
生产环境:通过 SMTP 发送邮件。
|
||||
"""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_verification_email(to_email: str, code: str, purpose: str) -> MIMEMultipart:
|
||||
"""构建验证码邮件"""
|
||||
purpose_text = {
|
||||
"register": "注册账号",
|
||||
"login": "登录",
|
||||
"reset_password": "重置密码",
|
||||
}.get(purpose, "操作")
|
||||
|
||||
subject = f"【{settings.APP_NAME}】{purpose_text}验证码"
|
||||
html = f"""
|
||||
<div style="max-width: 480px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
|
||||
<div style="background: linear-gradient(135deg, #6366F1, #4F46E5); padding: 32px; border-radius: 12px 12px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">{settings.APP_NAME}</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #E5E7EB; border-top: none; border-radius: 0 0 12px 12px;">
|
||||
<p style="color: #374151; font-size: 16px; margin: 0 0 16px;">您好,</p>
|
||||
<p style="color: #374151; font-size: 16px; margin: 0 0 24px;">
|
||||
您正在{purpose_text},验证码为:
|
||||
</p>
|
||||
<div style="background: #F3F4F6; padding: 20px; border-radius: 8px; text-align: center; margin: 0 0 24px;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #4F46E5;">{code}</span>
|
||||
</div>
|
||||
<p style="color: #6B7280; font-size: 14px; margin: 0 0 8px;">
|
||||
验证码 {settings.VERIFICATION_CODE_EXPIRE_MINUTES} 分钟内有效,请勿泄露给他人。
|
||||
</p>
|
||||
<p style="color: #9CA3AF; font-size: 12px; margin: 16px 0 0;">
|
||||
如非本人操作,请忽略此邮件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||
msg["To"] = to_email
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
return msg
|
||||
|
||||
|
||||
def send_verification_email(to_email: str, code: str, purpose: str = "register") -> bool:
|
||||
"""
|
||||
发送验证码邮件。
|
||||
|
||||
开发环境下仅打印到控制台,不实际发送。
|
||||
返回 True 表示成功。
|
||||
"""
|
||||
purpose_text = {
|
||||
"register": "注册",
|
||||
"login": "登录",
|
||||
"reset_password": "重置密码",
|
||||
}.get(purpose, "操作")
|
||||
|
||||
# 开发环境:仅打印到控制台
|
||||
if settings.ENVIRONMENT == "development" or not settings.SMTP_HOST:
|
||||
logger.info(
|
||||
"\n"
|
||||
"============================================\n"
|
||||
" 邮箱验证码 (开发模式 - 未实际发送)\n"
|
||||
" 收件人: %s\n"
|
||||
" 用途: %s\n"
|
||||
" 验证码: %s\n"
|
||||
" 有效期: %d 分钟\n"
|
||||
"============================================",
|
||||
to_email, purpose_text, code,
|
||||
settings.VERIFICATION_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
return True
|
||||
|
||||
# 生产环境:通过 SMTP 发送
|
||||
try:
|
||||
msg = _build_verification_email(to_email, code, purpose)
|
||||
|
||||
if settings.SMTP_USE_SSL:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_USER, [to_email], msg.as_string())
|
||||
server.quit()
|
||||
|
||||
logger.info("验证码邮件已发送: %s (%s)", to_email, purpose_text)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("发送验证码邮件失败: %s", to_email)
|
||||
return False
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
健康检查服务
|
||||
提供依赖注入接口,便于测试 mock
|
||||
"""
|
||||
from typing import Protocol, Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
|
||||
class HealthChecker(Protocol):
|
||||
"""健康检查协议(用于类型提示)"""
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
"""检查数据库连接"""
|
||||
...
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
"""检查 Redis 连接"""
|
||||
...
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
"""检查所有依赖"""
|
||||
...
|
||||
|
||||
|
||||
class DefaultHealthChecker:
|
||||
"""
|
||||
默认健康检查实现
|
||||
生产环境使用,检查真实依赖
|
||||
"""
|
||||
|
||||
# 默认连接超时(秒)
|
||||
DEFAULT_CONNECT_TIMEOUT = 5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_engine: Optional[AsyncEngine] = None,
|
||||
redis_url: Optional[str] = None,
|
||||
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||
):
|
||||
self._db_engine = db_engine
|
||||
self._redis_url = redis_url
|
||||
self._connect_timeout = connect_timeout
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
"""
|
||||
检查数据库连接
|
||||
|
||||
Returns:
|
||||
bool: 数据库是否可用
|
||||
"""
|
||||
if self._db_engine is None:
|
||||
# 未配置数据库引擎,尝试从全局获取
|
||||
try:
|
||||
from app.database import engine
|
||||
self._db_engine = engine
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with self._db_engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
"""
|
||||
检查 Redis 连接
|
||||
|
||||
Returns:
|
||||
bool: Redis 是否可用
|
||||
"""
|
||||
if self._redis_url is None:
|
||||
# 未配置 Redis URL,尝试从配置获取
|
||||
try:
|
||||
from app.config import settings
|
||||
self._redis_url = settings.REDIS_URL
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
client = aioredis.from_url(
|
||||
self._redis_url,
|
||||
socket_connect_timeout=self._connect_timeout
|
||||
)
|
||||
try:
|
||||
await client.ping()
|
||||
return True
|
||||
finally:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
"""检查所有依赖"""
|
||||
return {
|
||||
"database": await self.check_database(),
|
||||
"redis": await self.check_redis(),
|
||||
}
|
||||
|
||||
|
||||
class MockHealthChecker:
|
||||
"""
|
||||
Mock 健康检查实现
|
||||
测试环境使用,可配置返回值
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database_healthy: bool = True,
|
||||
redis_healthy: bool = True,
|
||||
):
|
||||
self._database_healthy = database_healthy
|
||||
self._redis_healthy = redis_healthy
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
return self._database_healthy
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
return self._redis_healthy
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
return {
|
||||
"database": self._database_healthy,
|
||||
"redis": self._redis_healthy,
|
||||
}
|
||||
|
||||
|
||||
def get_health_checker() -> HealthChecker:
|
||||
"""
|
||||
获取健康检查器依赖
|
||||
|
||||
生产环境返回 DefaultHealthChecker(检查真实依赖)
|
||||
测试环境通过 app.dependency_overrides 替换
|
||||
"""
|
||||
return DefaultHealthChecker()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
关键帧提取服务
|
||||
使用 FFmpeg 从视频中提取关键帧用于视觉分析
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyFrame:
|
||||
"""关键帧数据"""
|
||||
timestamp: float # 时间戳(秒)
|
||||
file_path: str # 帧图片路径
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
def to_base64(self) -> str:
|
||||
"""将帧图片转为 base64"""
|
||||
with open(self.file_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
def to_data_url(self) -> str:
|
||||
"""将帧图片转为 data URL"""
|
||||
return f"data:image/jpeg;base64,{self.to_base64()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
"""提取结果"""
|
||||
success: bool
|
||||
frames: list[KeyFrame] = field(default_factory=list)
|
||||
video_duration: float = 0.0
|
||||
error: Optional[str] = None
|
||||
output_dir: Optional[str] = None
|
||||
|
||||
|
||||
class KeyFrameExtractor:
|
||||
"""关键帧提取器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
ffprobe_path: str = "ffprobe",
|
||||
output_format: str = "jpg",
|
||||
quality: int = 2, # 1-31, 越小质量越高
|
||||
):
|
||||
"""
|
||||
初始化提取器
|
||||
|
||||
Args:
|
||||
ffmpeg_path: ffmpeg 可执行文件路径
|
||||
ffprobe_path: ffprobe 可执行文件路径
|
||||
output_format: 输出格式 (jpg/png)
|
||||
quality: JPEG 质量 (1-31)
|
||||
"""
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.ffprobe_path = ffprobe_path
|
||||
self.output_format = output_format
|
||||
self.quality = quality
|
||||
|
||||
def _check_ffmpeg(self) -> bool:
|
||||
"""检查 FFmpeg 是否可用"""
|
||||
return shutil.which(self.ffmpeg_path) is not None
|
||||
|
||||
async def get_video_info(self, video_path: str) -> dict:
|
||||
"""
|
||||
获取视频信息
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
Returns:
|
||||
视频信息字典
|
||||
"""
|
||||
cmd = [
|
||||
self.ffprobe_path,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
video_path,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
|
||||
import json
|
||||
info = json.loads(stdout.decode())
|
||||
|
||||
# 提取关键信息
|
||||
duration = float(info.get("format", {}).get("duration", 0))
|
||||
video_stream = next(
|
||||
(s for s in info.get("streams", []) if s.get("codec_type") == "video"),
|
||||
{}
|
||||
)
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"width": video_stream.get("width", 0),
|
||||
"height": video_stream.get("height", 0),
|
||||
"fps": eval(video_stream.get("r_frame_rate", "0/1")) if "/" in video_stream.get("r_frame_rate", "0") else 0,
|
||||
"codec": video_stream.get("codec_name", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "duration": 0}
|
||||
|
||||
async def extract_at_intervals(
|
||||
self,
|
||||
video_path: str,
|
||||
interval_seconds: float = 1.0,
|
||||
max_frames: int = 60,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> ExtractionResult:
|
||||
"""
|
||||
按时间间隔提取帧
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
interval_seconds: 提取间隔(秒)
|
||||
max_frames: 最大帧数
|
||||
output_dir: 输出目录,默认创建临时目录
|
||||
|
||||
Returns:
|
||||
ExtractionResult: 提取结果
|
||||
"""
|
||||
if not self._check_ffmpeg():
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="FFmpeg 未安装或不在 PATH 中",
|
||||
)
|
||||
|
||||
# 获取视频信息
|
||||
video_info = await self.get_video_info(video_path)
|
||||
duration = video_info.get("duration", 0)
|
||||
|
||||
if duration <= 0:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="无法获取视频时长",
|
||||
)
|
||||
|
||||
# 创建输出目录
|
||||
if output_dir is None:
|
||||
output_dir = tempfile.mkdtemp(prefix="keyframes_")
|
||||
else:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 计算实际帧数
|
||||
frame_count = min(int(duration / interval_seconds), max_frames)
|
||||
if frame_count <= 0:
|
||||
frame_count = 1
|
||||
|
||||
# 使用 FFmpeg 提取帧
|
||||
output_pattern = os.path.join(output_dir, f"frame_%04d.{self.output_format}")
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vf", f"fps=1/{interval_seconds}",
|
||||
"-frames:v", str(frame_count),
|
||||
"-q:v", str(self.quality),
|
||||
"-y",
|
||||
output_pattern,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=f"FFmpeg 错误: {stderr.decode()[:200]}",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
# 收集提取的帧
|
||||
frames = []
|
||||
for i in range(1, frame_count + 1):
|
||||
frame_path = os.path.join(output_dir, f"frame_{i:04d}.{self.output_format}")
|
||||
if os.path.exists(frame_path):
|
||||
timestamp = (i - 1) * interval_seconds
|
||||
frames.append(KeyFrame(
|
||||
timestamp=timestamp,
|
||||
file_path=frame_path,
|
||||
width=video_info.get("width", 0),
|
||||
height=video_info.get("height", 0),
|
||||
))
|
||||
|
||||
return ExtractionResult(
|
||||
success=True,
|
||||
frames=frames,
|
||||
video_duration=duration,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
async def extract_scene_changes(
|
||||
self,
|
||||
video_path: str,
|
||||
threshold: float = 0.3,
|
||||
max_frames: int = 30,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> ExtractionResult:
|
||||
"""
|
||||
基于场景变化提取关键帧
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
threshold: 场景变化阈值 (0-1)
|
||||
max_frames: 最大帧数
|
||||
output_dir: 输出目录
|
||||
|
||||
Returns:
|
||||
ExtractionResult: 提取结果
|
||||
"""
|
||||
if not self._check_ffmpeg():
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="FFmpeg 未安装或不在 PATH 中",
|
||||
)
|
||||
|
||||
video_info = await self.get_video_info(video_path)
|
||||
duration = video_info.get("duration", 0)
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = tempfile.mkdtemp(prefix="keyframes_")
|
||||
else:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_pattern = os.path.join(output_dir, f"scene_%04d.{self.output_format}")
|
||||
|
||||
# 使用场景检测滤镜
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vf", f"select='gt(scene,{threshold})',showinfo",
|
||||
"-vsync", "vfr",
|
||||
"-frames:v", str(max_frames),
|
||||
"-q:v", str(self.quality),
|
||||
"-y",
|
||||
output_pattern,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
# 解析时间戳
|
||||
timestamps = []
|
||||
for line in stderr.decode().split("\n"):
|
||||
if "pts_time:" in line:
|
||||
try:
|
||||
pts_part = line.split("pts_time:")[1].split()[0]
|
||||
timestamps.append(float(pts_part))
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# 收集帧
|
||||
frames = []
|
||||
for i, ts in enumerate(timestamps[:max_frames], 1):
|
||||
frame_path = os.path.join(output_dir, f"scene_{i:04d}.{self.output_format}")
|
||||
if os.path.exists(frame_path):
|
||||
frames.append(KeyFrame(
|
||||
timestamp=ts,
|
||||
file_path=frame_path,
|
||||
width=video_info.get("width", 0),
|
||||
height=video_info.get("height", 0),
|
||||
))
|
||||
|
||||
# 如果场景检测帧太少,补充均匀采样
|
||||
if len(frames) < 5 and duration > 0:
|
||||
interval_result = await self.extract_at_intervals(
|
||||
video_path,
|
||||
interval_seconds=duration / 10,
|
||||
max_frames=10,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
if interval_result.success:
|
||||
# 合并并去重
|
||||
existing_ts = {f.timestamp for f in frames}
|
||||
for f in interval_result.frames:
|
||||
if f.timestamp not in existing_ts:
|
||||
frames.append(f)
|
||||
frames.sort(key=lambda x: x.timestamp)
|
||||
|
||||
return ExtractionResult(
|
||||
success=True,
|
||||
frames=frames[:max_frames],
|
||||
video_duration=duration,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
def cleanup(self, output_dir: str) -> bool:
|
||||
"""
|
||||
清理提取的临时文件
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# 全局实例
|
||||
_extractor: Optional[KeyFrameExtractor] = None
|
||||
|
||||
|
||||
def get_keyframe_extractor() -> KeyFrameExtractor:
|
||||
"""获取关键帧提取器单例"""
|
||||
global _extractor
|
||||
if _extractor is None:
|
||||
_extractor = KeyFrameExtractor()
|
||||
return _extractor
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
消息服务
|
||||
"""
|
||||
import secrets
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from app.models.message import Message
|
||||
|
||||
|
||||
def _generate_message_id() -> str:
|
||||
"""生成消息 ID"""
|
||||
random_part = secrets.randbelow(900000) + 100000
|
||||
return f"MSG{random_part}"
|
||||
|
||||
|
||||
async def create_message(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
type: str,
|
||||
title: str,
|
||||
content: str,
|
||||
related_task_id: Optional[str] = None,
|
||||
related_project_id: Optional[str] = None,
|
||||
sender_name: Optional[str] = None,
|
||||
) -> Message:
|
||||
"""创建消息"""
|
||||
message = Message(
|
||||
id=_generate_message_id(),
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
content=content,
|
||||
is_read=False,
|
||||
related_task_id=related_task_id,
|
||||
related_project_id=related_project_id,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
db.add(message)
|
||||
await db.flush()
|
||||
return message
|
||||
|
||||
|
||||
async def list_messages(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
) -> Tuple[List[Message], int]:
|
||||
"""查询消息列表"""
|
||||
query = select(Message).where(Message.user_id == user_id)
|
||||
count_query = select(func.count()).select_from(Message).where(Message.user_id == user_id)
|
||||
|
||||
if is_read is not None:
|
||||
query = query.where(Message.is_read == is_read)
|
||||
count_query = count_query.where(Message.is_read == is_read)
|
||||
|
||||
if type is not None:
|
||||
query = query.where(Message.type == type)
|
||||
count_query = count_query.where(Message.type == type)
|
||||
|
||||
# 总数
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.order_by(Message.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
return messages, total
|
||||
|
||||
|
||||
async def get_unread_count(db: AsyncSession, user_id: str) -> int:
|
||||
"""获取未读消息数"""
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(Message).where(
|
||||
Message.user_id == user_id,
|
||||
Message.is_read == False,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def mark_as_read(db: AsyncSession, message_id: str, user_id: str) -> bool:
|
||||
"""标记单条消息已读"""
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id == message_id,
|
||||
Message.user_id == user_id,
|
||||
)
|
||||
)
|
||||
message = result.scalar_one_or_none()
|
||||
if not message:
|
||||
return False
|
||||
|
||||
message.is_read = True
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def mark_all_as_read(db: AsyncSession, user_id: str) -> int:
|
||||
"""标记所有消息已读,返回更新数量"""
|
||||
result = await db.execute(
|
||||
update(Message)
|
||||
.where(Message.user_id == user_id, Message.is_read == False)
|
||||
.values(is_read=True)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
火山引擎 TOS (Volcengine Object Storage) 服务 — 表单直传签名 (V4)
|
||||
"""
|
||||
import time
|
||||
import hmac
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_upload_policy(
|
||||
max_size_mb: int = 500,
|
||||
expire_seconds: int = 3600,
|
||||
upload_dir: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
生成前端直传 TOS 所需的 Policy 和签名 (V4 HMAC-SHA256)
|
||||
|
||||
TOS 表单直传签名流程 (PostObject):
|
||||
1. 构建 policy JSON → Base64 编码
|
||||
2. 派生签名密钥: kDate → kRegion → kService → kSigning
|
||||
3. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||
"x_tos_credential": "AKIDxxxx/20260210/cn-beijing/tos/request",
|
||||
"x_tos_date": "20260210T120000Z",
|
||||
"x_tos_signature": "...",
|
||||
"policy": "base64 encoded policy",
|
||||
"host": "https://bucket.tos-cn-beijing.volces.com",
|
||||
"dir": "uploads/2026/02/",
|
||||
"expire": 1234567890,
|
||||
}
|
||||
"""
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
raise ValueError("TOS 配置未设置")
|
||||
|
||||
# 计算时间
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
date_stamp = now_utc.strftime("%Y%m%d") # 20260210
|
||||
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ") # 20260210T120000Z
|
||||
expire_time = int(time.time()) + expire_seconds
|
||||
expiration = datetime.fromtimestamp(expire_time, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
|
||||
# Credential scope
|
||||
region = settings.TOS_REGION
|
||||
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
|
||||
|
||||
# 默认上传目录:uploads/年/月/
|
||||
if upload_dir is None:
|
||||
now = datetime.now()
|
||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
||||
|
||||
# 1. 构建 Policy
|
||||
policy_dict = {
|
||||
"expiration": expiration,
|
||||
"conditions": [
|
||||
{"bucket": settings.TOS_BUCKET_NAME},
|
||||
["starts-with", "$key", upload_dir],
|
||||
{"x-tos-algorithm": "TOS4-HMAC-SHA256"},
|
||||
{"x-tos-credential": credential},
|
||||
{"x-tos-date": tos_date},
|
||||
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||
],
|
||||
}
|
||||
|
||||
# 2. Base64 编码 Policy
|
||||
policy_json = json.dumps(policy_dict)
|
||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||
|
||||
# 3. 派生签名密钥 (V4 Signing Key)
|
||||
k_date = hmac.new(
|
||||
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
|
||||
date_stamp.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
|
||||
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
|
||||
|
||||
# 4. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||
signature = hmac.new(
|
||||
k_signing,
|
||||
policy_base64.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
# 构建 Host
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
|
||||
return {
|
||||
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||
"x_tos_credential": credential,
|
||||
"x_tos_date": tos_date,
|
||||
"x_tos_signature": signature,
|
||||
"policy": policy_base64,
|
||||
"host": host,
|
||||
"dir": upload_dir,
|
||||
"expire": expire_time,
|
||||
}
|
||||
|
||||
|
||||
def get_file_url(file_key: str) -> str:
|
||||
"""
|
||||
获取文件的访问 URL
|
||||
|
||||
优先使用 CDN 域名,否则用 TOS 源站域名。
|
||||
|
||||
Args:
|
||||
file_key: 文件在 TOS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||
|
||||
Returns:
|
||||
完整的访问 URL
|
||||
"""
|
||||
if settings.TOS_CDN_DOMAIN:
|
||||
host = settings.TOS_CDN_DOMAIN
|
||||
else:
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
|
||||
# 确保 host 以 https:// 开头
|
||||
if not host.startswith("http"):
|
||||
host = f"https://{host}"
|
||||
|
||||
# 确保 host 不以 / 结尾
|
||||
host = host.rstrip("/")
|
||||
|
||||
# 确保 file_key 不以 / 开头
|
||||
file_key = file_key.lstrip("/")
|
||||
|
||||
return f"{host}/{file_key}"
|
||||
|
||||
|
||||
def generate_presigned_url(
|
||||
file_key: str,
|
||||
expire_seconds: int = 3600,
|
||||
) -> str:
|
||||
"""
|
||||
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
|
||||
|
||||
签名流程:
|
||||
1. 构建 CanonicalRequest
|
||||
2. 构建 StringToSign
|
||||
3. 用派生密钥签名
|
||||
4. 拼接查询参数
|
||||
|
||||
Args:
|
||||
file_key: 文件在 TOS 中的 key
|
||||
expire_seconds: URL 有效期(秒),默认 1 小时
|
||||
|
||||
Returns:
|
||||
预签名 URL
|
||||
"""
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
raise ValueError("TOS 配置未设置")
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
bucket = settings.TOS_BUCKET_NAME
|
||||
host = f"{bucket}.{endpoint}"
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
date_stamp = now_utc.strftime("%Y%m%d")
|
||||
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ")
|
||||
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
|
||||
|
||||
# 对 file_key 中的路径段分别编码
|
||||
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
|
||||
|
||||
# 查询参数(按字母序排列)
|
||||
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 = (
|
||||
f"GET\n"
|
||||
f"/{encoded_key}\n"
|
||||
f"{query_params}\n"
|
||||
f"host:{host}\n"
|
||||
f"\n"
|
||||
f"host\n"
|
||||
f"UNSIGNED-PAYLOAD"
|
||||
)
|
||||
|
||||
# StringToSign
|
||||
canonical_request_hash = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||||
string_to_sign = (
|
||||
f"TOS4-HMAC-SHA256\n"
|
||||
f"{tos_date}\n"
|
||||
f"{date_stamp}/{region}/tos/request\n"
|
||||
f"{canonical_request_hash}"
|
||||
)
|
||||
|
||||
# 派生签名密钥
|
||||
k_date = hmac.new(
|
||||
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
|
||||
date_stamp.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
|
||||
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
|
||||
|
||||
# 计算签名
|
||||
signature = hmac.new(
|
||||
k_signing,
|
||||
string_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return (
|
||||
f"https://{host}/{encoded_key}"
|
||||
f"?{query_params}"
|
||||
f"&X-Tos-Signature={signature}"
|
||||
)
|
||||
|
||||
|
||||
def parse_file_key_from_url(url: str) -> str:
|
||||
"""
|
||||
从完整 URL 解析出文件 key
|
||||
|
||||
Args:
|
||||
url: 完整的 TOS URL
|
||||
|
||||
Returns:
|
||||
文件 key
|
||||
"""
|
||||
# 尝试移除 CDN 域名
|
||||
if settings.TOS_CDN_DOMAIN:
|
||||
cdn = settings.TOS_CDN_DOMAIN.rstrip("/")
|
||||
if not cdn.startswith("http"):
|
||||
cdn = f"https://{cdn}"
|
||||
if url.startswith(cdn):
|
||||
return url[len(cdn):].lstrip("/")
|
||||
|
||||
# 尝试移除 TOS 源站域名
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||
tos_host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||
if url.startswith(tos_host):
|
||||
return url[len(tos_host):].lstrip("/")
|
||||
|
||||
return url
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
风险分类服务
|
||||
根据违规类型判断风险等级
|
||||
"""
|
||||
from app.schemas.review import ViolationType, RiskLevel
|
||||
|
||||
|
||||
def classify_risk_level(violation_type: ViolationType) -> RiskLevel:
|
||||
"""
|
||||
根据违规类型分类风险等级
|
||||
|
||||
规则:
|
||||
- 高风险 (HIGH): 法律违规(广告法极限词、功效宣称)
|
||||
- 中风险 (MEDIUM): 平台规则违规(竞品露出、时长不足)
|
||||
- 低风险 (LOW): 品牌规范违规(品牌提及不足)
|
||||
|
||||
Args:
|
||||
violation_type: 违规类型
|
||||
|
||||
Returns:
|
||||
RiskLevel: 风险等级
|
||||
"""
|
||||
high_risk_types = {
|
||||
ViolationType.FORBIDDEN_WORD,
|
||||
ViolationType.EFFICACY_CLAIM,
|
||||
}
|
||||
|
||||
medium_risk_types = {
|
||||
ViolationType.COMPETITOR_LOGO,
|
||||
ViolationType.DURATION_SHORT,
|
||||
ViolationType.BRAND_SAFETY,
|
||||
}
|
||||
|
||||
low_risk_types = {
|
||||
ViolationType.MENTION_MISSING,
|
||||
}
|
||||
|
||||
if violation_type in high_risk_types:
|
||||
return RiskLevel.HIGH
|
||||
elif violation_type in medium_risk_types:
|
||||
return RiskLevel.MEDIUM
|
||||
elif violation_type in low_risk_types:
|
||||
return RiskLevel.LOW
|
||||
else:
|
||||
# 默认中风险
|
||||
return RiskLevel.MEDIUM
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
特例审批服务
|
||||
超时策略、审批流程
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.review import (
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
)
|
||||
|
||||
|
||||
# 超时时间(小时)
|
||||
TIMEOUT_HOURS = 48
|
||||
|
||||
|
||||
def apply_timeout_policy(
|
||||
record: RiskExceptionRecord,
|
||||
current_time: datetime,
|
||||
) -> RiskExceptionRecord:
|
||||
"""
|
||||
应用超时策略
|
||||
|
||||
规则:
|
||||
- 超过 48 小时未审批 → 自动拒绝
|
||||
- 记录自动拒绝原因
|
||||
|
||||
Args:
|
||||
record: 特例记录
|
||||
current_time: 当前时间
|
||||
|
||||
Returns:
|
||||
更新后的记录
|
||||
"""
|
||||
# 只处理待审批状态
|
||||
if record.status != RiskExceptionStatus.PENDING:
|
||||
return record
|
||||
|
||||
# 计算时间差
|
||||
apply_time = record.apply_time
|
||||
if isinstance(apply_time, str):
|
||||
apply_time = datetime.fromisoformat(apply_time.replace("Z", "+00:00"))
|
||||
|
||||
# 确保时区一致
|
||||
if apply_time.tzinfo is None:
|
||||
apply_time = apply_time.replace(tzinfo=timezone.utc)
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
elapsed = current_time - apply_time
|
||||
|
||||
if elapsed > timedelta(hours=TIMEOUT_HOURS):
|
||||
# 超时自动拒绝
|
||||
return RiskExceptionRecord(
|
||||
record_id=record.record_id,
|
||||
applicant_id=record.applicant_id,
|
||||
apply_time=record.apply_time,
|
||||
target_type=record.target_type,
|
||||
target_id=record.target_id,
|
||||
risk_rule_id=record.risk_rule_id,
|
||||
status=RiskExceptionStatus.REJECTED,
|
||||
valid_start_time=record.valid_start_time,
|
||||
valid_end_time=record.valid_end_time,
|
||||
reason_category=record.reason_category,
|
||||
justification=record.justification,
|
||||
attachment_url=record.attachment_url,
|
||||
current_approver_id=record.current_approver_id,
|
||||
approval_chain_log=record.approval_chain_log,
|
||||
auto_rejected=True,
|
||||
rejection_reason="timeout",
|
||||
last_status_at=current_time,
|
||||
)
|
||||
|
||||
return record
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
软性风控服务
|
||||
临界值、低置信度、历史记录触发警告
|
||||
"""
|
||||
from app.schemas.review import (
|
||||
SoftRiskContext,
|
||||
SoftRiskWarning,
|
||||
SoftRiskAction,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_soft_risk(context: SoftRiskContext) -> list[SoftRiskWarning]:
|
||||
"""
|
||||
评估软性风控
|
||||
|
||||
规则:
|
||||
- 违规率接近阈值(90% 以上)→ 二次确认
|
||||
- ASR/OCR 置信度 60%-80% → 备注提示
|
||||
- 有历史类似违规 → 备注提示
|
||||
|
||||
Args:
|
||||
context: 软性风控上下文
|
||||
|
||||
Returns:
|
||||
警告列表(可能为空)
|
||||
"""
|
||||
warnings: list[SoftRiskWarning] = []
|
||||
|
||||
# 1. 临界值检测
|
||||
if (
|
||||
context.violation_rate is not None
|
||||
and context.violation_threshold is not None
|
||||
and context.violation_threshold > 0
|
||||
):
|
||||
ratio = context.violation_rate / context.violation_threshold
|
||||
# 使用 round 避免浮点数精度问题 (0.045/0.05 = 0.8999999999999999)
|
||||
ratio = round(ratio, 10)
|
||||
if ratio >= 0.9 and ratio < 1.0:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="NEAR_THRESHOLD",
|
||||
message=f"违规率 {context.violation_rate:.1%} 接近阈值 {context.violation_threshold:.1%}",
|
||||
action_required=SoftRiskAction.CONFIRM,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 2. ASR 低置信度检测
|
||||
if context.asr_confidence is not None:
|
||||
if 0.6 <= context.asr_confidence < 0.8:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="LOW_CONFIDENCE_ASR",
|
||||
message=f"语音识别置信度较低 ({context.asr_confidence:.0%}),建议人工复核",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 3. OCR 低置信度检测
|
||||
if context.ocr_confidence is not None:
|
||||
if 0.6 <= context.ocr_confidence < 0.8:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="LOW_CONFIDENCE_OCR",
|
||||
message=f"字幕识别置信度较低 ({context.ocr_confidence:.0%}),建议人工复核",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 4. 历史违规检测
|
||||
if context.has_history_violation:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="HISTORY_RISK",
|
||||
message="该达人/内容存在历史类似违规记录",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
return warnings
|
||||
@@ -0,0 +1,694 @@
|
||||
"""
|
||||
任务服务
|
||||
处理任务的创建、状态流转、审核等业务逻辑
|
||||
"""
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.task import Task, TaskStage, TaskStatus
|
||||
from app.models.project import Project
|
||||
from app.models.organization import Brand, Agency, Creator
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.auth import generate_id
|
||||
|
||||
|
||||
async def get_next_task_sequence(
|
||||
db: AsyncSession,
|
||||
project_id: str,
|
||||
creator_id: str,
|
||||
) -> int:
|
||||
"""获取该项目下该达人的下一个任务序号"""
|
||||
result = await db.execute(
|
||||
select(func.count(Task.id)).where(
|
||||
and_(
|
||||
Task.project_id == project_id,
|
||||
Task.creator_id == creator_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
return count + 1
|
||||
|
||||
|
||||
async def create_task(
|
||||
db: AsyncSession,
|
||||
project_id: str,
|
||||
agency_id: str,
|
||||
creator_id: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Task:
|
||||
"""
|
||||
创建任务(代理商操作)
|
||||
|
||||
- 自动生成任务名称 "宣传任务(N)"
|
||||
- 初始阶段: script_upload
|
||||
"""
|
||||
# 获取序号
|
||||
sequence = await get_next_task_sequence(db, project_id, creator_id)
|
||||
|
||||
# 生成任务名称
|
||||
if not name:
|
||||
name = f"宣传任务({sequence})"
|
||||
|
||||
task = Task(
|
||||
id=generate_id("TK"),
|
||||
project_id=project_id,
|
||||
agency_id=agency_id,
|
||||
creator_id=creator_id,
|
||||
name=name,
|
||||
sequence=sequence,
|
||||
stage=TaskStage.SCRIPT_UPLOAD,
|
||||
appeal_count=1, # 初始申诉次数
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def get_task_by_id(
|
||||
db: AsyncSession,
|
||||
task_id: str,
|
||||
) -> Optional[Task]:
|
||||
"""通过 ID 获取任务(带关联加载)"""
|
||||
result = await db.execute(
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(Task.id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_task_permission(
|
||||
task: Task,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
检查用户是否有权限访问任务
|
||||
|
||||
- 达人: 只能访问分配给自己的任务
|
||||
- 代理商: 只能访问自己创建的任务
|
||||
- 品牌方: 可以访问自己项目下的所有任务
|
||||
"""
|
||||
if user.role == UserRole.CREATOR:
|
||||
result = await db.execute(
|
||||
select(Creator).where(Creator.user_id == user.id)
|
||||
)
|
||||
creator = result.scalar_one_or_none()
|
||||
return creator and task.creator_id == creator.id
|
||||
|
||||
elif user.role == UserRole.AGENCY:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.user_id == user.id)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
return agency and task.agency_id == agency.id
|
||||
|
||||
elif user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.user_id == user.id)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
if not brand:
|
||||
return False
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
return project and project.brand_id == brand.id
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def upload_script(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
file_url: str,
|
||||
file_name: str,
|
||||
) -> Task:
|
||||
"""
|
||||
上传脚本(达人操作)
|
||||
|
||||
- 更新脚本信息
|
||||
- 状态流转到 script_ai_review
|
||||
"""
|
||||
if task.stage not in [TaskStage.SCRIPT_UPLOAD, TaskStage.REJECTED]:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不允许上传脚本")
|
||||
|
||||
task.script_file_url = file_url
|
||||
task.script_file_name = file_name
|
||||
task.script_uploaded_at = datetime.now(timezone.utc)
|
||||
task.stage = TaskStage.SCRIPT_AI_REVIEW
|
||||
|
||||
# 如果是申诉重新上传,重置申诉状态
|
||||
if task.is_appeal:
|
||||
task.is_appeal = False
|
||||
task.appeal_reason = None
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def upload_video(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
file_url: str,
|
||||
file_name: str,
|
||||
duration: Optional[int] = None,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
) -> Task:
|
||||
"""
|
||||
上传视频(达人操作)
|
||||
|
||||
- 更新视频信息
|
||||
- 状态流转到 video_ai_review
|
||||
"""
|
||||
if task.stage not in [TaskStage.VIDEO_UPLOAD, TaskStage.REJECTED]:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不允许上传视频")
|
||||
|
||||
task.video_file_url = file_url
|
||||
task.video_file_name = file_name
|
||||
task.video_duration = duration
|
||||
task.video_thumbnail_url = thumbnail_url
|
||||
task.video_uploaded_at = datetime.now(timezone.utc)
|
||||
task.stage = TaskStage.VIDEO_AI_REVIEW
|
||||
|
||||
# 如果是申诉重新上传,重置申诉状态
|
||||
if task.is_appeal:
|
||||
task.is_appeal = False
|
||||
task.appeal_reason = None
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
AI_AUTO_REJECT_SCORE = 40
|
||||
|
||||
|
||||
def _check_ai_auto_reject(score: int, result: dict) -> tuple[bool, str]:
|
||||
"""
|
||||
判断 AI 审核结果是否应自动驳回
|
||||
|
||||
触发条件(任一):
|
||||
1. 法规合规维度存在 HIGH 级违规(违禁词/功效词)
|
||||
2. 品牌安全维度存在 HIGH 级违规(竞品提及)
|
||||
3. 总分 < 40
|
||||
"""
|
||||
reasons = []
|
||||
violations = result.get("violations", [])
|
||||
|
||||
# 条件1: 法规 HIGH
|
||||
high_legal = [v for v in violations if v.get("dimension") == "legal" and v.get("severity") == "high"]
|
||||
if high_legal:
|
||||
words = [v.get("content", "") for v in high_legal[:5]]
|
||||
reasons.append(f"法规违规:{', '.join(words)}")
|
||||
|
||||
# 条件2: 品牌安全 HIGH
|
||||
high_brand = [v for v in violations if v.get("dimension") == "brand_safety" and v.get("severity") == "high"]
|
||||
if high_brand:
|
||||
words = [v.get("content", "") for v in high_brand[:5]]
|
||||
reasons.append(f"品牌安全违规:{', '.join(words)}")
|
||||
|
||||
# 条件3: 总分过低
|
||||
if score < AI_AUTO_REJECT_SCORE:
|
||||
reasons.append(f"综合评分 {score} 分,低于合格线 {AI_AUTO_REJECT_SCORE} 分")
|
||||
|
||||
if reasons:
|
||||
return True, ";".join(reasons)
|
||||
return False, ""
|
||||
|
||||
|
||||
async def complete_ai_review(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
review_type: str, # "script" or "video"
|
||||
score: int,
|
||||
result: dict,
|
||||
) -> Task:
|
||||
"""
|
||||
完成 AI 审核
|
||||
|
||||
- 更新 AI 审核结果
|
||||
- 自动驳回:法规/品牌安全 HIGH 违规或总分 < 40 → 回到上传阶段
|
||||
- 正常:流转到代理商审核
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
auto_rejected, reject_reason = _check_ai_auto_reject(score, result)
|
||||
|
||||
# 将自动驳回信息写入 result,前端可据此展示
|
||||
if auto_rejected:
|
||||
result["ai_auto_rejected"] = True
|
||||
result["ai_reject_reason"] = reject_reason
|
||||
|
||||
if review_type == "script":
|
||||
if task.stage != TaskStage.SCRIPT_AI_REVIEW:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不在脚本 AI 审核中")
|
||||
|
||||
task.script_ai_score = score
|
||||
task.script_ai_result = result
|
||||
task.script_ai_reviewed_at = now
|
||||
|
||||
if auto_rejected:
|
||||
task.stage = TaskStage.SCRIPT_UPLOAD
|
||||
else:
|
||||
task.stage = TaskStage.SCRIPT_AGENCY_REVIEW
|
||||
|
||||
elif review_type == "video":
|
||||
if task.stage != TaskStage.VIDEO_AI_REVIEW:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不在视频 AI 审核中")
|
||||
|
||||
task.video_ai_score = score
|
||||
task.video_ai_result = result
|
||||
task.video_ai_reviewed_at = now
|
||||
|
||||
if auto_rejected:
|
||||
task.stage = TaskStage.VIDEO_UPLOAD
|
||||
else:
|
||||
task.stage = TaskStage.VIDEO_AGENCY_REVIEW
|
||||
|
||||
else:
|
||||
raise ValueError(f"不支持的审核类型: {review_type}")
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def agency_review(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
reviewer_id: str,
|
||||
action: str, # "pass" | "reject" | "force_pass"
|
||||
comment: Optional[str] = None,
|
||||
) -> Task:
|
||||
"""
|
||||
代理商审核
|
||||
|
||||
- pass: 通过,进入品牌方审核(如果开启)或下一阶段
|
||||
- reject: 驳回,回到上传阶段
|
||||
- force_pass: 强制通过,跳过品牌方审核
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 获取项目信息以检查是否开启品牌方终审
|
||||
project = await db.execute(
|
||||
select(Project)
|
||||
.options(selectinload(Project.brand))
|
||||
.where(Project.id == task.project_id)
|
||||
)
|
||||
project = project.scalar_one_or_none()
|
||||
brand_review_enabled = project and project.brand and project.brand.final_review_enabled
|
||||
|
||||
if task.stage == TaskStage.SCRIPT_AGENCY_REVIEW:
|
||||
if action == "pass":
|
||||
task.script_agency_status = TaskStatus.PASSED
|
||||
if brand_review_enabled:
|
||||
task.stage = TaskStage.SCRIPT_BRAND_REVIEW
|
||||
else:
|
||||
task.stage = TaskStage.VIDEO_UPLOAD
|
||||
elif action == "reject":
|
||||
task.script_agency_status = TaskStatus.REJECTED
|
||||
task.stage = TaskStage.REJECTED
|
||||
elif action == "force_pass":
|
||||
task.script_agency_status = TaskStatus.FORCE_PASSED
|
||||
task.stage = TaskStage.VIDEO_UPLOAD # 跳过品牌方审核
|
||||
else:
|
||||
raise ValueError(f"不支持的操作: {action}")
|
||||
|
||||
task.script_agency_comment = comment
|
||||
task.script_agency_reviewer_id = reviewer_id
|
||||
task.script_agency_reviewed_at = now
|
||||
|
||||
elif task.stage == TaskStage.VIDEO_AGENCY_REVIEW:
|
||||
if action == "pass":
|
||||
task.video_agency_status = TaskStatus.PASSED
|
||||
if brand_review_enabled:
|
||||
task.stage = TaskStage.VIDEO_BRAND_REVIEW
|
||||
else:
|
||||
task.stage = TaskStage.COMPLETED
|
||||
elif action == "reject":
|
||||
task.video_agency_status = TaskStatus.REJECTED
|
||||
task.stage = TaskStage.REJECTED
|
||||
elif action == "force_pass":
|
||||
task.video_agency_status = TaskStatus.FORCE_PASSED
|
||||
task.stage = TaskStage.COMPLETED # 跳过品牌方审核
|
||||
else:
|
||||
raise ValueError(f"不支持的操作: {action}")
|
||||
|
||||
task.video_agency_comment = comment
|
||||
task.video_agency_reviewer_id = reviewer_id
|
||||
task.video_agency_reviewed_at = now
|
||||
|
||||
else:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不在代理商审核中")
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def brand_review(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
reviewer_id: str,
|
||||
action: str, # "pass" | "reject"
|
||||
comment: Optional[str] = None,
|
||||
) -> Task:
|
||||
"""
|
||||
品牌方终审
|
||||
|
||||
- pass: 通过,进入下一阶段
|
||||
- reject: 驳回,回到上传阶段(需要走申诉流程)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if task.stage == TaskStage.SCRIPT_BRAND_REVIEW:
|
||||
if action == "pass":
|
||||
task.script_brand_status = TaskStatus.PASSED
|
||||
task.stage = TaskStage.VIDEO_UPLOAD
|
||||
elif action == "reject":
|
||||
task.script_brand_status = TaskStatus.REJECTED
|
||||
task.stage = TaskStage.REJECTED
|
||||
else:
|
||||
raise ValueError(f"不支持的操作: {action}")
|
||||
|
||||
task.script_brand_comment = comment
|
||||
task.script_brand_reviewer_id = reviewer_id
|
||||
task.script_brand_reviewed_at = now
|
||||
|
||||
elif task.stage == TaskStage.VIDEO_BRAND_REVIEW:
|
||||
if action == "pass":
|
||||
task.video_brand_status = TaskStatus.PASSED
|
||||
task.stage = TaskStage.COMPLETED
|
||||
elif action == "reject":
|
||||
task.video_brand_status = TaskStatus.REJECTED
|
||||
task.stage = TaskStage.REJECTED
|
||||
else:
|
||||
raise ValueError(f"不支持的操作: {action}")
|
||||
|
||||
task.video_brand_comment = comment
|
||||
task.video_brand_reviewer_id = reviewer_id
|
||||
task.video_brand_reviewed_at = now
|
||||
|
||||
else:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不在品牌方审核中")
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def submit_appeal(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
reason: str,
|
||||
) -> Task:
|
||||
"""
|
||||
提交申诉(达人操作)
|
||||
|
||||
- 使用一次申诉次数
|
||||
- 回到对应的上传阶段
|
||||
"""
|
||||
if task.stage != TaskStage.REJECTED:
|
||||
raise ValueError(f"当前阶段 {task.stage.value} 不允许申诉")
|
||||
|
||||
if task.appeal_count <= 0:
|
||||
raise ValueError("申诉次数已用完,请联系代理商申请增加")
|
||||
|
||||
# 消耗一次申诉次数
|
||||
task.appeal_count -= 1
|
||||
task.is_appeal = True
|
||||
task.appeal_reason = reason
|
||||
|
||||
# 根据驳回阶段回到对应的上传阶段
|
||||
# 检查是脚本阶段被驳回还是视频阶段被驳回
|
||||
if task.video_agency_status == TaskStatus.REJECTED or task.video_brand_status == TaskStatus.REJECTED:
|
||||
task.stage = TaskStage.VIDEO_UPLOAD
|
||||
# 重置视频审核状态
|
||||
task.video_agency_status = None
|
||||
task.video_brand_status = None
|
||||
else:
|
||||
task.stage = TaskStage.SCRIPT_UPLOAD
|
||||
# 重置脚本审核状态
|
||||
task.script_agency_status = None
|
||||
task.script_brand_status = None
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def increase_appeal_count(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
additional_count: int = 1,
|
||||
) -> Task:
|
||||
"""
|
||||
增加申诉次数(代理商操作)
|
||||
"""
|
||||
task.appeal_count += additional_count
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
async def list_tasks_for_creator(
|
||||
db: AsyncSession,
|
||||
creator_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取达人的任务列表"""
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(Task.creator_id == creator_id)
|
||||
)
|
||||
|
||||
if stage:
|
||||
query = query.where(Task.stage == stage)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
# 获取总数
|
||||
count_query = select(func.count(Task.id)).where(Task.creator_id == creator_id)
|
||||
if stage:
|
||||
count_query = count_query.where(Task.stage == stage)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
return tasks, total
|
||||
|
||||
|
||||
async def list_tasks_for_agency(
|
||||
db: AsyncSession,
|
||||
agency_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取代理商的任务列表"""
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(Task.agency_id == agency_id)
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
# 获取总数
|
||||
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
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
return tasks, total
|
||||
|
||||
|
||||
async def list_tasks_for_brand(
|
||||
db: AsyncSession,
|
||||
brand_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取品牌方的任务列表(通过项目关联)"""
|
||||
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
|
||||
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(Task.project_id.in_(project_ids))
|
||||
)
|
||||
|
||||
if stage:
|
||||
query = query.where(Task.stage == stage)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
# 获取总数
|
||||
count_query = select(func.count(Task.id)).where(Task.project_id.in_(project_ids))
|
||||
if stage:
|
||||
count_query = count_query.where(Task.stage == stage)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
return tasks, total
|
||||
|
||||
|
||||
async def list_pending_reviews_for_agency(
|
||||
db: AsyncSession,
|
||||
agency_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取代理商待审核的任务列表"""
|
||||
stages = [TaskStage.SCRIPT_AGENCY_REVIEW, TaskStage.VIDEO_AGENCY_REVIEW]
|
||||
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Task.agency_id == agency_id,
|
||||
Task.stage.in_(stages),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
# 获取总数
|
||||
count_query = select(func.count(Task.id)).where(
|
||||
and_(
|
||||
Task.agency_id == agency_id,
|
||||
Task.stage.in_(stages),
|
||||
)
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
return tasks, total
|
||||
|
||||
|
||||
async def list_pending_reviews_for_brand(
|
||||
db: AsyncSession,
|
||||
brand_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> 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 not project_ids:
|
||||
return [], 0
|
||||
|
||||
stages = [TaskStage.SCRIPT_BRAND_REVIEW, TaskStage.VIDEO_BRAND_REVIEW]
|
||||
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project).selectinload(Project.brand),
|
||||
selectinload(Task.agency),
|
||||
selectinload(Task.creator),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Task.project_id.in_(project_ids),
|
||||
Task.stage.in_(stages),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
# 获取总数
|
||||
count_query = select(func.count(Task.id)).where(
|
||||
and_(
|
||||
Task.project_id.in_(project_ids),
|
||||
Task.stage.in_(stages),
|
||||
)
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
return tasks, total
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
验证码服务
|
||||
|
||||
使用内存存储验证码,支持 TTL 自动过期。
|
||||
生产环境建议替换为 Redis 存储。
|
||||
"""
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 内存存储: { "email:purpose" -> (code, expire_timestamp) }
|
||||
_code_store: dict[str, tuple[str, float]] = {}
|
||||
|
||||
# 发送频率限制: { "email:purpose" -> last_send_timestamp }
|
||||
_rate_limit: dict[str, float] = {}
|
||||
|
||||
# 最小发送间隔(秒)
|
||||
SEND_INTERVAL = 60
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""清理过期的验证码"""
|
||||
now = time.time()
|
||||
expired_keys = [k for k, (_, exp) in _code_store.items() if now > exp]
|
||||
for k in expired_keys:
|
||||
del _code_store[k]
|
||||
|
||||
|
||||
def generate_code(email: str, purpose: str = "register") -> tuple[str, Optional[str]]:
|
||||
"""
|
||||
生成验证码并存储。
|
||||
|
||||
返回 (code, error)。
|
||||
error 为 None 表示成功,否则返回错误信息。
|
||||
"""
|
||||
_cleanup_expired()
|
||||
|
||||
key = f"{email}:{purpose}"
|
||||
|
||||
# 检查发送频率
|
||||
now = time.time()
|
||||
last_sent = _rate_limit.get(key, 0)
|
||||
if now - last_sent < SEND_INTERVAL:
|
||||
remaining = int(SEND_INTERVAL - (now - last_sent))
|
||||
return "", f"发送过于频繁,请 {remaining} 秒后重试"
|
||||
|
||||
# 生成验证码
|
||||
code = "".join(str(secrets.randbelow(10)) for _ in range(settings.VERIFICATION_CODE_LENGTH))
|
||||
|
||||
# 存储(带 TTL)
|
||||
expire_at = now + settings.VERIFICATION_CODE_EXPIRE_MINUTES * 60
|
||||
_code_store[key] = (code, expire_at)
|
||||
_rate_limit[key] = now
|
||||
|
||||
logger.info("验证码已生成: email=%s, purpose=%s", email, purpose)
|
||||
return code, None
|
||||
|
||||
|
||||
def verify_code(email: str, code: str, purpose: str = "register") -> bool:
|
||||
"""
|
||||
验证验证码是否正确。
|
||||
|
||||
验证成功后自动删除验证码(一次性使用)。
|
||||
"""
|
||||
_cleanup_expired()
|
||||
|
||||
key = f"{email}:{purpose}"
|
||||
stored = _code_store.get(key)
|
||||
|
||||
if not stored:
|
||||
return False
|
||||
|
||||
stored_code, expire_at = stored
|
||||
|
||||
# 已过期
|
||||
if time.time() > expire_at:
|
||||
del _code_store[key]
|
||||
return False
|
||||
|
||||
# 验证码匹配
|
||||
if stored_code == code:
|
||||
del _code_store[key] # 一次性使用
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clear_all() -> None:
|
||||
"""清除所有验证码(用于测试)"""
|
||||
_code_store.clear()
|
||||
_rate_limit.clear()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
视频下载服务
|
||||
从 URL 下载视频到临时目录,支持重试和进度回调
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
"""下载结果"""
|
||||
success: bool
|
||||
file_path: Optional[str] = None
|
||||
file_size: int = 0
|
||||
content_type: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class VideoDownloadService:
|
||||
"""视频下载服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
temp_dir: Optional[str] = None,
|
||||
max_file_size: int = 500 * 1024 * 1024, # 500MB
|
||||
timeout: float = 300.0, # 5 分钟
|
||||
chunk_size: int = 1024 * 1024, # 1MB
|
||||
):
|
||||
"""
|
||||
初始化下载服务
|
||||
|
||||
Args:
|
||||
temp_dir: 临时目录,默认使用系统临时目录
|
||||
max_file_size: 最大文件大小(字节)
|
||||
timeout: 下载超时(秒)
|
||||
chunk_size: 分块大小(字节)
|
||||
"""
|
||||
self.temp_dir = temp_dir or tempfile.gettempdir()
|
||||
self.max_file_size = max_file_size
|
||||
self.timeout = timeout
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
# 确保临时目录存在
|
||||
Path(self.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _generate_filename(self, url: str, content_type: Optional[str] = None) -> str:
|
||||
"""根据 URL 生成唯一文件名"""
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
|
||||
# 根据 content-type 确定扩展名
|
||||
ext = ".mp4"
|
||||
if content_type:
|
||||
ext_map = {
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-msvideo": ".avi",
|
||||
"video/x-matroska": ".mkv",
|
||||
}
|
||||
ext = ext_map.get(content_type, ".mp4")
|
||||
|
||||
return f"video_{url_hash}{ext}"
|
||||
|
||||
async def download(
|
||||
self,
|
||||
url: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
max_retries: int = 3,
|
||||
) -> DownloadResult:
|
||||
"""
|
||||
下载视频文件
|
||||
|
||||
Args:
|
||||
url: 视频 URL
|
||||
progress_callback: 进度回调函数 (downloaded_bytes, total_bytes)
|
||||
max_retries: 最大重试次数
|
||||
|
||||
Returns:
|
||||
DownloadResult: 下载结果
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await self._download_once(url, progress_callback)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
|
||||
# 重试前等待
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"下载失败(已重试 {max_retries} 次): {last_error}",
|
||||
)
|
||||
|
||||
async def _download_once(
|
||||
self,
|
||||
url: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> DownloadResult:
|
||||
"""单次下载尝试"""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
# 先获取文件信息
|
||||
head_resp = await client.head(url)
|
||||
if head_resp.status_code >= 400:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"HTTP {head_resp.status_code}",
|
||||
)
|
||||
|
||||
content_type = head_resp.headers.get("content-type", "")
|
||||
content_length = int(head_resp.headers.get("content-length", 0))
|
||||
|
||||
# 检查文件大小
|
||||
if content_length > self.max_file_size:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"文件过大: {content_length / 1024 / 1024:.1f}MB > {self.max_file_size / 1024 / 1024:.1f}MB",
|
||||
)
|
||||
|
||||
# 检查是否为视频类型
|
||||
if content_type and not content_type.startswith("video/"):
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"非视频文件类型: {content_type}",
|
||||
)
|
||||
|
||||
# 生成本地文件路径
|
||||
filename = self._generate_filename(url, content_type)
|
||||
file_path = os.path.join(self.temp_dir, filename)
|
||||
|
||||
# 如果文件已存在且大小匹配,直接返回
|
||||
if os.path.exists(file_path):
|
||||
existing_size = os.path.getsize(file_path)
|
||||
if existing_size == content_length:
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
file_size=existing_size,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# 流式下载
|
||||
downloaded = 0
|
||||
async with client.stream("GET", url) as response:
|
||||
if response.status_code >= 400:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=self.chunk_size):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# 检查是否超过最大限制
|
||||
if downloaded > self.max_file_size:
|
||||
os.remove(file_path)
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"文件过大,已下载 {downloaded / 1024 / 1024:.1f}MB",
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, content_length or downloaded)
|
||||
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
file_size=downloaded,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def cleanup(self, file_path: str) -> bool:
|
||||
"""
|
||||
清理下载的临时文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def cleanup_old_files(self, max_age_seconds: int = 3600) -> int:
|
||||
"""
|
||||
清理过期的临时文件
|
||||
|
||||
Args:
|
||||
max_age_seconds: 最大文件年龄(秒)
|
||||
|
||||
Returns:
|
||||
删除的文件数量
|
||||
"""
|
||||
import time
|
||||
|
||||
deleted = 0
|
||||
now = time.time()
|
||||
|
||||
for filename in os.listdir(self.temp_dir):
|
||||
if not filename.startswith("video_"):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(self.temp_dir, filename)
|
||||
try:
|
||||
file_age = now - os.path.getmtime(file_path)
|
||||
if file_age > max_age_seconds:
|
||||
os.remove(file_path)
|
||||
deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
# 全局实例
|
||||
_download_service: Optional[VideoDownloadService] = None
|
||||
|
||||
|
||||
def get_download_service() -> VideoDownloadService:
|
||||
"""获取下载服务单例"""
|
||||
global _download_service
|
||||
if _download_service is None:
|
||||
_download_service = VideoDownloadService()
|
||||
return _download_service
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
视频审核服务
|
||||
核心业务逻辑:违规检测、时长校验、风险分类、分数计算
|
||||
"""
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
class VideoReviewService:
|
||||
"""视频审核服务"""
|
||||
|
||||
def __init__(self):
|
||||
# AI 服务依赖(可注入 mock)
|
||||
self.asr_service: Optional[AsyncMock] = None
|
||||
self.cv_service: Optional[AsyncMock] = None
|
||||
self.ocr_service: Optional[AsyncMock] = None
|
||||
|
||||
async def detect_competitor_logos(
|
||||
self,
|
||||
frames: list[dict],
|
||||
competitors: list[str],
|
||||
min_confidence: float = 0.7,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测画面中的竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 视频帧数据,每帧包含 timestamp 和 objects
|
||||
competitors: 竞品列表
|
||||
min_confidence: 最小置信度阈值
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
for frame in frames:
|
||||
timestamp = frame.get("timestamp", 0.0)
|
||||
objects = frame.get("objects", [])
|
||||
|
||||
for obj in objects:
|
||||
label = obj.get("label", "")
|
||||
confidence = obj.get("confidence", 0.0)
|
||||
|
||||
if label in competitors and confidence >= min_confidence:
|
||||
violations.append({
|
||||
"type": "competitor_logo",
|
||||
"timestamp": timestamp,
|
||||
"content": label,
|
||||
"confidence": confidence,
|
||||
"risk_level": "medium",
|
||||
"suggestion": f"请移除画面中的竞品露出:{label}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def detect_forbidden_words_in_speech(
|
||||
self,
|
||||
transcript: list[dict],
|
||||
forbidden_words: list[str],
|
||||
context_aware: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测语音转文字中的违禁词
|
||||
|
||||
Args:
|
||||
transcript: ASR 转写结果,每段包含 text, start, end
|
||||
forbidden_words: 违禁词列表
|
||||
context_aware: 是否启用语境感知
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
|
||||
# 广告语境关键词
|
||||
ad_context_keywords = ["产品", "购买", "推荐", "选择", "品牌", "效果"]
|
||||
|
||||
for segment in transcript:
|
||||
text = segment.get("text", "")
|
||||
start = segment.get("start", 0.0)
|
||||
|
||||
for word in forbidden_words:
|
||||
if word in text:
|
||||
# 语境感知检测
|
||||
if context_aware:
|
||||
is_ad_context = any(kw in text for kw in ad_context_keywords)
|
||||
if not is_ad_context:
|
||||
continue # 非广告语境,跳过
|
||||
|
||||
violations.append({
|
||||
"type": "forbidden_word",
|
||||
"content": word,
|
||||
"timestamp": start,
|
||||
"source": "speech",
|
||||
"risk_level": "high",
|
||||
"suggestion": f"建议删除或替换违禁词:{word}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def detect_forbidden_words_in_subtitle(
|
||||
self,
|
||||
subtitles: list[dict],
|
||||
forbidden_words: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测字幕中的违禁词
|
||||
|
||||
Args:
|
||||
subtitles: OCR 提取的字幕,每条包含 text, timestamp
|
||||
forbidden_words: 违禁词列表
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
|
||||
for subtitle in subtitles:
|
||||
text = subtitle.get("text", "")
|
||||
timestamp = subtitle.get("timestamp", 0.0)
|
||||
|
||||
for word in forbidden_words:
|
||||
if word in text:
|
||||
violations.append({
|
||||
"type": "forbidden_word",
|
||||
"content": word,
|
||||
"timestamp": timestamp,
|
||||
"source": "subtitle",
|
||||
"risk_level": "high",
|
||||
"suggestion": f"建议删除字幕中的违禁词:{word}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def check_product_display_duration(
|
||||
self,
|
||||
appearances: list[dict],
|
||||
min_seconds: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
校验产品同框时长
|
||||
|
||||
Args:
|
||||
appearances: 产品出现时间段列表,每段包含 start, end
|
||||
min_seconds: 最小要求秒数
|
||||
|
||||
Returns:
|
||||
违规列表(如果时长不足)
|
||||
"""
|
||||
total_duration = 0.0
|
||||
for appearance in appearances:
|
||||
start = appearance.get("start", 0.0)
|
||||
end = appearance.get("end", 0.0)
|
||||
total_duration += (end - start)
|
||||
|
||||
if total_duration < min_seconds:
|
||||
return [{
|
||||
"type": "duration_short",
|
||||
"content": f"产品同框时长 {total_duration:.0f} 秒,不足要求的 {min_seconds} 秒",
|
||||
"timestamp": 0.0,
|
||||
"risk_level": "medium",
|
||||
"suggestion": f"建议增加产品同框时长至 {min_seconds} 秒以上",
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
async def check_brand_mention_frequency(
|
||||
self,
|
||||
transcript: list[dict],
|
||||
brand_name: str,
|
||||
min_mentions: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
校验品牌提及频次
|
||||
|
||||
Args:
|
||||
transcript: ASR 转写结果
|
||||
brand_name: 品牌名称
|
||||
min_mentions: 最小提及次数
|
||||
|
||||
Returns:
|
||||
违规列表(如果提及不足)
|
||||
"""
|
||||
mention_count = 0
|
||||
for segment in transcript:
|
||||
text = segment.get("text", "")
|
||||
mention_count += text.count(brand_name)
|
||||
|
||||
if mention_count < min_mentions:
|
||||
return [{
|
||||
"type": "mention_missing",
|
||||
"content": f"品牌 '{brand_name}' 提及 {mention_count} 次,不足要求的 {min_mentions} 次",
|
||||
"timestamp": 0.0,
|
||||
"risk_level": "low",
|
||||
"suggestion": f"建议增加品牌提及至 {min_mentions} 次以上",
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
def classify_risk_level(self, violation: dict) -> str:
|
||||
"""
|
||||
根据违规项分类风险等级
|
||||
|
||||
Args:
|
||||
violation: 违规项
|
||||
|
||||
Returns:
|
||||
风险等级: high/medium/low
|
||||
"""
|
||||
violation_type = violation.get("type", "")
|
||||
category = violation.get("category", "")
|
||||
|
||||
# 法律违规 -> 高风险
|
||||
if category == "absolute_term" or violation_type == "forbidden_word":
|
||||
return "high"
|
||||
|
||||
# 平台规则违规 -> 中风险
|
||||
if category == "platform_rule" or violation_type in ["duration_short", "competitor_logo"]:
|
||||
return "medium"
|
||||
|
||||
# 品牌规范违规 -> 低风险
|
||||
if category == "brand_guideline" or violation_type == "mention_missing":
|
||||
return "low"
|
||||
|
||||
return "medium" # 默认中风险
|
||||
|
||||
def calculate_score(self, violations: list[dict]) -> int:
|
||||
"""
|
||||
计算合规分数
|
||||
|
||||
规则:
|
||||
- 基础分 100 分
|
||||
- 高风险违规扣 25 分
|
||||
- 中风险违规扣 15 分
|
||||
- 低风险违规扣 5 分
|
||||
- 最低 0 分
|
||||
|
||||
Args:
|
||||
violations: 违规列表
|
||||
|
||||
Returns:
|
||||
合规分数 (0-100)
|
||||
"""
|
||||
score = 100
|
||||
|
||||
for violation in violations:
|
||||
risk_level = violation.get("risk_level", "medium")
|
||||
|
||||
if risk_level == "high":
|
||||
score -= 25
|
||||
elif risk_level == "medium":
|
||||
score -= 15
|
||||
else:
|
||||
score -= 5
|
||||
|
||||
return max(0, score)
|
||||
|
||||
async def review_video(
|
||||
self,
|
||||
video_url: str,
|
||||
platform: str,
|
||||
brand_id: str,
|
||||
competitors: list[str] = None,
|
||||
forbidden_words: list[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
完整视频审核流程
|
||||
|
||||
Args:
|
||||
video_url: 视频 URL
|
||||
platform: 投放平台
|
||||
brand_id: 品牌 ID
|
||||
competitors: 竞品列表
|
||||
forbidden_words: 违禁词列表
|
||||
|
||||
Returns:
|
||||
审核结果
|
||||
"""
|
||||
competitors = competitors or []
|
||||
forbidden_words = forbidden_words or []
|
||||
all_violations = []
|
||||
|
||||
# 1. ASR 语音转文字 + 违禁词检测
|
||||
if self.asr_service:
|
||||
transcript = await self.asr_service.transcribe(video_url)
|
||||
speech_violations = await self.detect_forbidden_words_in_speech(
|
||||
transcript, forbidden_words
|
||||
)
|
||||
all_violations.extend(speech_violations)
|
||||
|
||||
# 2. CV 物体检测 + 竞品 Logo 检测
|
||||
if self.cv_service:
|
||||
frames = await self.cv_service.detect_objects(video_url)
|
||||
logo_violations = await self.detect_competitor_logos(frames, competitors)
|
||||
all_violations.extend(logo_violations)
|
||||
|
||||
# 3. OCR 字幕提取 + 违禁词检测
|
||||
if self.ocr_service:
|
||||
subtitles = await self.ocr_service.extract_subtitles(video_url)
|
||||
subtitle_violations = await self.detect_forbidden_words_in_subtitle(
|
||||
subtitles, forbidden_words
|
||||
)
|
||||
all_violations.extend(subtitle_violations)
|
||||
|
||||
# 4. 计算分数
|
||||
score = self.calculate_score(all_violations)
|
||||
|
||||
# 5. 生成摘要
|
||||
if not all_violations:
|
||||
summary = "视频内容合规,未发现违规项"
|
||||
else:
|
||||
summary = f"发现 {len(all_violations)} 处违规"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": summary,
|
||||
"violations": all_violations,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
视觉分析服务
|
||||
集成 GPT-4V 实现竞品 Logo 检测、画面分析、OCR 字幕提取
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.keyframe import KeyFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedObject:
|
||||
"""检测到的对象"""
|
||||
label: str
|
||||
confidence: float
|
||||
timestamp: float
|
||||
bounding_box: Optional[dict] = None # {x, y, width, height}
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleSegment:
|
||||
"""字幕片段"""
|
||||
text: str
|
||||
timestamp: float
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisionAnalysisResult:
|
||||
"""视觉分析结果"""
|
||||
success: bool
|
||||
detected_logos: list[DetectedObject] = field(default_factory=list)
|
||||
detected_texts: list[SubtitleSegment] = field(default_factory=list)
|
||||
scene_description: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class VisionAnalysisService:
|
||||
"""视觉分析服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
max_tokens: int = 2000,
|
||||
):
|
||||
"""
|
||||
初始化视觉分析服务
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
base_url: API 基础 URL
|
||||
model: 视觉模型名称
|
||||
max_tokens: 最大输出 token
|
||||
"""
|
||||
self.client = OpenAICompatibleClient(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
self.model = model
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
async def detect_logos(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitor_names: list[str],
|
||||
batch_size: int = 5,
|
||||
) -> VisionAnalysisResult:
|
||||
"""
|
||||
检测画面中的竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 关键帧列表
|
||||
competitor_names: 竞品名称列表
|
||||
batch_size: 每批处理的帧数
|
||||
|
||||
Returns:
|
||||
VisionAnalysisResult: 分析结果
|
||||
"""
|
||||
if not frames:
|
||||
return VisionAnalysisResult(success=True)
|
||||
|
||||
all_logos = []
|
||||
competitors_str = "、".join(competitor_names) if competitor_names else "任何品牌"
|
||||
|
||||
# 分批处理帧
|
||||
for i in range(0, len(frames), batch_size):
|
||||
batch = frames[i:i + batch_size]
|
||||
|
||||
try:
|
||||
result = await self._analyze_frames_for_logos(
|
||||
batch,
|
||||
competitors_str,
|
||||
)
|
||||
all_logos.extend(result)
|
||||
except Exception as e:
|
||||
# 单批失败不影响整体
|
||||
continue
|
||||
|
||||
return VisionAnalysisResult(
|
||||
success=True,
|
||||
detected_logos=all_logos,
|
||||
)
|
||||
|
||||
async def _analyze_frames_for_logos(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitors_str: str,
|
||||
) -> list[DetectedObject]:
|
||||
"""分析一批帧中的 Logo"""
|
||||
# 构建图片内容
|
||||
image_contents = []
|
||||
timestamps = []
|
||||
|
||||
for frame in frames:
|
||||
base64_image = frame.to_base64()
|
||||
image_contents.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "low",
|
||||
},
|
||||
})
|
||||
timestamps.append(frame.timestamp)
|
||||
|
||||
prompt = f"""分析这些视频帧,检测是否出现以下竞品品牌的 Logo 或产品:{competitors_str}
|
||||
|
||||
请以 JSON 格式返回检测结果,格式如下:
|
||||
{{
|
||||
"detections": [
|
||||
{{
|
||||
"frame_index": 0,
|
||||
"brand": "品牌名称",
|
||||
"confidence": 0.9,
|
||||
"description": "Logo 出现在画面左上角"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
如果没有检测到任何竞品,返回空数组:{{"detections": []}}
|
||||
只返回 JSON,不要其他文字。"""
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}] + image_contents,
|
||||
}]
|
||||
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.1,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
try:
|
||||
content = response.content.strip()
|
||||
# 尝试提取 JSON
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0]
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0]
|
||||
|
||||
data = json.loads(content)
|
||||
detections = data.get("detections", [])
|
||||
|
||||
result = []
|
||||
for det in detections:
|
||||
frame_idx = det.get("frame_index", 0)
|
||||
if 0 <= frame_idx < len(timestamps):
|
||||
result.append(DetectedObject(
|
||||
label=det.get("brand", ""),
|
||||
confidence=det.get("confidence", 0.8),
|
||||
timestamp=timestamps[frame_idx],
|
||||
description=det.get("description", ""),
|
||||
))
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
async def extract_text_from_frames(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
batch_size: int = 5,
|
||||
) -> VisionAnalysisResult:
|
||||
"""
|
||||
从帧中提取文字(OCR)
|
||||
|
||||
Args:
|
||||
frames: 关键帧列表
|
||||
batch_size: 每批处理的帧数
|
||||
|
||||
Returns:
|
||||
VisionAnalysisResult: 分析结果
|
||||
"""
|
||||
if not frames:
|
||||
return VisionAnalysisResult(success=True)
|
||||
|
||||
all_texts = []
|
||||
|
||||
for i in range(0, len(frames), batch_size):
|
||||
batch = frames[i:i + batch_size]
|
||||
|
||||
try:
|
||||
result = await self._extract_text_from_batch(batch)
|
||||
all_texts.extend(result)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return VisionAnalysisResult(
|
||||
success=True,
|
||||
detected_texts=all_texts,
|
||||
)
|
||||
|
||||
async def _extract_text_from_batch(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
) -> list[SubtitleSegment]:
|
||||
"""从一批帧中提取文字"""
|
||||
image_contents = []
|
||||
timestamps = []
|
||||
|
||||
for frame in frames:
|
||||
base64_image = frame.to_base64()
|
||||
image_contents.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "high",
|
||||
},
|
||||
})
|
||||
timestamps.append(frame.timestamp)
|
||||
|
||||
prompt = """提取这些视频帧中的所有可见文字,特别是字幕和标题。
|
||||
|
||||
请以 JSON 格式返回,格式如下:
|
||||
{
|
||||
"texts": [
|
||||
{
|
||||
"frame_index": 0,
|
||||
"text": "提取到的文字内容",
|
||||
"type": "subtitle"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
type 可以是: subtitle(字幕), title(标题), caption(说明文字), other(其他)
|
||||
如果没有文字,返回空数组:{"texts": []}
|
||||
只返回 JSON,不要其他文字。"""
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}] + image_contents,
|
||||
}]
|
||||
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.1,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
|
||||
try:
|
||||
content = response.content.strip()
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0]
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0]
|
||||
|
||||
data = json.loads(content)
|
||||
texts = data.get("texts", [])
|
||||
|
||||
result = []
|
||||
for txt in texts:
|
||||
frame_idx = txt.get("frame_index", 0)
|
||||
if 0 <= frame_idx < len(timestamps):
|
||||
text_content = txt.get("text", "").strip()
|
||||
if text_content:
|
||||
result.append(SubtitleSegment(
|
||||
text=text_content,
|
||||
timestamp=timestamps[frame_idx],
|
||||
))
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
async def analyze_scene(
|
||||
self,
|
||||
frame: KeyFrame,
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
分析单帧场景
|
||||
|
||||
Args:
|
||||
frame: 关键帧
|
||||
context: 额外上下文
|
||||
|
||||
Returns:
|
||||
场景描述
|
||||
"""
|
||||
base64_image = frame.to_base64()
|
||||
|
||||
prompt = f"请简要描述这个视频画面的内容,特别关注:产品、人物、场景、文字。{context}"
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "low",
|
||||
},
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
try:
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.3,
|
||||
max_tokens=500,
|
||||
)
|
||||
return response.content.strip()
|
||||
except Exception as e:
|
||||
return f"分析失败: {str(e)}"
|
||||
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
await self.client.close()
|
||||
|
||||
|
||||
class CompetitorLogoDetector:
|
||||
"""竞品 Logo 检测器(封装简化接口)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
):
|
||||
self.service = VisionAnalysisService(api_key, base_url, model)
|
||||
|
||||
async def detect(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitors: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 关键帧
|
||||
competitors: 竞品列表
|
||||
|
||||
Returns:
|
||||
违规列表(兼容 VideoReviewService 格式)
|
||||
"""
|
||||
result = await self.service.detect_logos(frames, competitors)
|
||||
|
||||
violations = []
|
||||
for logo in result.detected_logos:
|
||||
if logo.label in competitors or any(c in logo.label for c in competitors):
|
||||
violations.append({
|
||||
"type": "competitor_logo",
|
||||
"timestamp": logo.timestamp,
|
||||
"timestamp_end": logo.timestamp + 1.0,
|
||||
"content": logo.label,
|
||||
"confidence": logo.confidence,
|
||||
"risk_level": "medium",
|
||||
"source": "visual",
|
||||
"suggestion": f"请移除画面中的竞品露出:{logo.label}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def close(self):
|
||||
await self.service.close()
|
||||
|
||||
|
||||
class VideoOCRService:
|
||||
"""视频 OCR 服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
):
|
||||
self.service = VisionAnalysisService(api_key, base_url, model)
|
||||
|
||||
async def extract_subtitles(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
提取字幕
|
||||
|
||||
Args:
|
||||
frames: 关键帧
|
||||
|
||||
Returns:
|
||||
字幕列表(兼容 VideoReviewService 格式)
|
||||
"""
|
||||
result = await self.service.extract_text_from_frames(frames)
|
||||
|
||||
subtitles = []
|
||||
for seg in result.detected_texts:
|
||||
subtitles.append({
|
||||
"text": seg.text,
|
||||
"timestamp": seg.timestamp,
|
||||
})
|
||||
|
||||
return subtitles
|
||||
|
||||
async def close(self):
|
||||
await self.service.close()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user