Compare commits

..
58 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.6 f392c514cd fix: 移除功效词默认兜底,品牌方未配置则不检查
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:25:00 +08:00
Your NameandClaude Opus 4.6 0b3dfa3c52 feat: AI 审核自动驳回 + 功效词可配置 + UI 修复
- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段
- 功效词可配置:从硬编码改为品牌方在规则页面自行管理
- 驳回通知:AI 驳回时只通知达人,含具体原因
- 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口
- 规则页面:新增"功效词"分类
- 种子数据:新增 6 条默认功效词
- 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:24:32 +08:00
Your NameandClaude Opus 4.6 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:11:54 +08:00
Your NameandClaude Opus 4.6 0c59797d5b fix: 文件下载改用 fetch+blob 方式,避免浏览器显示乱码
用 fetch 获取文件内容后创建 Blob URL 触发下载,
不依赖服务端 Content-Disposition 头。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:39:30 +08:00
Your NameandClaude Opus 4.6 9a0e7b356b fix: 文件下载乱码 — 签名 URL 增加 Content-Disposition: attachment
- generate_presigned_url 支持 download 参数,添加 response-content-disposition
- sign-url API 新增 download 查询参数
- 前端 getSignedUrl 支持 download 模式
- 下载时传 download=true,浏览器触发文件保存而非显示乱码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:18:28 +08:00
Your NameandClaude Opus 4.6 0ab58b7e6e fix: 代理商平台显示 + Brief 下载预览功能
- 后端 TaskResponse.ProjectInfo 新增 platform 字段
- 修复代理商 6 个页面硬编码 platform='douyin' 的问题,改为读取实际值
- Brief 预览弹窗:占位符改为 iframe/img 实际展示文件内容
  - PDF 用 iframe 在线预览
  - 图片直接展示
  - 其他类型提示下载
- Brief 下载:改用 a 标签触发下载

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:15:03 +08:00
Your NameandClaude Opus 4.6 4ca743e7b6 feat: 项目创建/分配代理商时发送消息通知给代理商
品牌方创建项目分配代理商、或后续追加代理商时,
被分配的代理商用户会收到"新项目分配"消息通知。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:02:08 +08:00
Your NameandClaude Opus 4.6 4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00
Your NameandClaude Opus 4.6 58aed5f201 feat: 私有桶签名 URL 支持 + TOS 凭证配置
后端 oss.py 新增 generate_presigned_url (TOS V4 Query String Auth),
upload.py 新增 GET /upload/sign-url 端点。前端 api.ts 添加 getSignedUrl
方法,新增 useSignedUrl hook 支持自动缓存和过期刷新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:54:16 +08:00
Your NameandClaude Opus 4.6 2f24dcfd34 feat: 规则冲突检测增强 — 后端接入 DB 规则 + 前端集成检查按钮
后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则,
新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面
添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:12:49 +08:00
Your NameandClaude Opus 4.6 c17c64cd11 test: 补全品牌方平台规则 API 测试覆盖 (22 个新测试)
- TestBrandPlatformRuleParse: 文档解析 6 个用例 (201/400/降级)
- TestBrandPlatformRuleConfirm: 确认生效 5 个用例 (active/编辑/停旧/404/隔离)
- TestBrandPlatformRuleList: 列表查询 5 个用例 (空/筛选/隔离)
- TestBrandPlatformRuleDelete: 删除 4 个用例 (204/实际删除/404/隔离)
- TestBrandPlatformRuleLifecycle: 完整生命周期 1 个用例
- 修复 confirm 端点 flush 后缺少 refresh 导致的 MissingGreenlet 错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:51:40 +08:00
Your NameandClaude Opus 4.6 3a2598c956 feat: 前端平台规则页面支持文档上传 + AI 解析
- 新增前端类型定义 (ParsedRulesData, BrandPlatformRuleResponse 等)
- 新增 4 个 API 方法 (parsePlatformRule, confirmPlatformRule, listBrandPlatformRules, deletePlatformRule)
- 重写品牌方规则页面平台规则 tab,支持文档上传→AI解析→确认/编辑→生效流程
- 保留违禁词/竞品/白名单三个 tab 原有功能
- 支持 USE_MOCK 双模式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:32:13 +08:00
Your NameandClaude Opus 4.6 fed361b9b3 feat: 平台规则从硬编码改为品牌方上传文档 + AI 解析
- 新增 PlatformRule 模型 (draft/active/inactive 状态流转)
- 新增文档解析服务 (PDF/Word/Excel → 纯文本)
- 新增 4 个 API: 解析/确认/查询/删除平台规则
- 脚本审核优先从 DB 读取 active 规则,硬编码兜底
- 视频审核合并平台规则违禁词到检测列表
- Alembic 迁移 006: platform_rules 表

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:23:11 +08:00
Your NameandClaude Opus 4.6 a2f6f82e15 chore: CI/CD + 前端测试 + 安全加固 + 限流完善
- 新增 .gitlab-ci.yml (lint/test/build 三阶段)
- 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16)
- 修复旧测试: Sidebar 导航文案、MobileLayout padding 值
- python-jose → PyJWT 消除 ecdsa CVE 漏洞
- 限流中间件增加 5 个敏感端点精细限流 + 标准限流头

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:25:29 +08:00
Your NameandClaude Opus 4.6 3a444864ac feat: 腾讯云 COS 迁移至火山引擎 TOS 对象存储
签名算法从 COS HMAC-SHA1 改为 TOS V4 HMAC-SHA256,
更新前后端上传凭证字段、配置项、备份脚本和文档。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:02:15 +08:00
Your NameandClaude Opus 4.6 eba9ce8e60 test: 补全 Profile 和 Messages API 测试覆盖
- test_profile_api.py: 16 个测试 (GET/PUT /profile + PUT /profile/password)
  - 三种角色资料获取、更新品牌方/代理商/达人字段、密码修改、权限校验
- test_messages_api.py: 16 个测试 (消息列表/分页/过滤/未读数/标记已读)
  - 分页、按类型/已读状态过滤、用户隔离、全部已读

32 个新测试全部通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:45:33 +08:00
Your NameandClaude Opus 4.6 8ab2d869fc feat: 阿里云 OSS 迁移至腾讯云 COS + 完善部署配置
COS 迁移:
- 后端签名服务改为 COS HMAC-SHA1 表单直传签名
- config.py: OSS_* 配置项替换为 COS_SECRET_ID/KEY/REGION/BUCKET_NAME/CDN_DOMAIN
- upload.py: UploadPolicyResponse 改为 COS 字段
- 前端 useOSSUpload hook: FormData 字段改为 COS 格式
- 前端 api.ts: UploadPolicyResponse 类型对齐

部署配置:
- docker-compose.yml: 新增 Nginx + 前端容器,数据卷宿主机持久化
- Nginx: HTTPS + HTTP/2 + SSE 长连接 + API/前端反向代理
- backup.sh: PostgreSQL 每日备份 → 本地 + COS
- .env.example: 更新为 COS 配置模板

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:28:13 +08:00
Your NameandClaude Opus 4.6 f02b3f4098 feat: 前端对接 Profile/Messages/Settings 页面 API
- 3 消息页 + 2 资料编辑页 + 3 设置页 + 2 资料展示页
- api.ts 新增 Profile/Messages/ChangePassword 等类型和方法
- SSEContext 事件映射修复 + 断线重连修复
- 剩余页面加 USE_MOCK 双模式,52/55 页面已完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:59 +08:00
Your NameandClaude Opus 4.6 a76c302d7a feat: 添加种子数据和一键初始化脚本
- demo 账号: brand/agency/creator@demo.com
- 组织关系 + 项目/Brief + 4种阶段任务 + 规则数据 + 示例消息
- entrypoint.sh (Docker) + init_db.sh (手动) + start-dev.sh 更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:47 +08:00
Your NameandClaude Opus 4.6 ea807974cf feat: 添加 Profile/Messages API 及 SSE 推送集成
- Profile API: GET/PUT /profile + PUT /profile/password
- Messages API: 模型/迁移(005)/服务/路由 + 任务操作自动创建消息
- SSE 推送集成: tasks.py 中 6 个操作触发 SSE 通知
- Alembic 迁移: 004 audit_logs + 005 messages
- env.py 导入所有模型确保迁移正确

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:37 +08:00
Your NameandClaude Opus 4.6 68dac332d4 feat: 对接达人端和代理商端个人中心页面 API
- creator/profile: 使用 useAuth() 获取真实用户数据,dashboard API 获取统计
- agency/profile: 同上,使用 getAgencyDashboard() 获取代理商统计数据
- 两端退出登录改为调用 logout() 清除 token,替代原 TODO 占位

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:21:04 +08:00
Your NameandClaude Opus 4.6 3a6e25b5b1 feat: 添加密码重置功能
- 后端: 新增 POST /auth/reset-password 端点(邮箱+验证码+新密码)
- 后端: 新增 ResetPasswordRequest schema
- 前端: 新增 /forgot-password 页面(分步骤:输入邮箱→验证码+新密码→完成)
- 前端: 登录页添加"忘记密码?"链接

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:00:42 +08:00
Your NameandClaude Opus 4.6 45c6c034e0 style: 优化登录页登录方式切换 Tab 样式
将白色背景切换改为下划线 Tab 样式,选中态使用紫色文字+底边框,辨识度更高。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:56:48 +08:00
Your NameandClaude Opus 4.6 d4081345f7 feat: 实现邮箱验证码注册/登录功能
- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出)
- 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途
- 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True
- 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式
- 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时)
- 前端: 登录页增加密码登录/验证码登录双Tab切换
- 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:49:47 +08:00
Your NameandClaude Opus 4.6 864af19011 test: 补全后端 API 测试覆盖(Organizations/Projects/Dashboard/Briefs/Export)
新增 157 个测试,总计 368 个测试全部通过:
- Organizations API: 50 tests (品牌方↔代理商↔达人关系管理 + 搜索 + 权限)
- Projects API: 44 tests (CRUD + 分页 + 状态筛选 + 代理商分配 + 权限)
- Dashboard API: 17 tests (三端工作台统计 + 角色隔离 + 认证)
- Briefs API: 24 tests (CRUD + 权限 + 数据完整性)
- Export API: 22 tests (CSV 导出 + UTF-8 BOM + 角色权限 + 格式验证)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:18:49 +08:00
Your NameandClaude Opus 4.6 f634879f1e fix: 修复达人端上传脚本按钮无响应问题
UploadView 组件的按钮缺少 onClick 处理,现改为点击后导航至专用上传页面。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:07:22 +08:00
Your NameandClaude Opus 4.6 86a7865808 feat: 添加 Docker 部署配置 + 安全加固 + 数据导出 API
- 新增 backend/Dockerfile + frontend/Dockerfile (多阶段构建)
- 新增 docker-compose.yml (postgres + redis + backend + frontend)
- 新增 .env.example 模板 (前后端)
- 新增 export API: 任务数据导出 + 审计日志导出 (CSV + 流式响应)
- 安全加固: CORS 从环境变量配置, 安全 headers 中间件
- 生产环境自动禁用 API 文档 (Swagger/Redoc)
- 添加 ENVIRONMENT, CORS_ORIGINS 配置项
- 前端启用 Next.js standalone 输出模式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:43:28 +08:00
Your NameandClaude Opus 4.6 e0bd3f2911 feat: 添加核心流程测试 + 审计日志 + 修复 task_service 嵌套加载 bug
- 新增 test_auth_api.py (48 tests): 注册/登录/刷新/退出全流程覆盖
- 新增 test_tasks_api.py (38 tests): 任务 CRUD/审核/申诉/权限控制
- 新增 AuditLog 模型 + log_action 审计服务
- 新增 logging_config.py 结构化日志配置
- 修复 task_service.py 缺少 Project.brand 嵌套加载导致的 MissingGreenlet 错误
- 修复 conftest.py 添加限流清理 fixture 防止测试间干扰
- 修复 TDD 红色阶段测试文件的 import 错误 (skip)
- auth.py 集成审计日志 (注册/登录/退出)
- 全部 211 tests passed, 2 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:39:18 +08:00
Your NameandClaude Opus 4.6 8eb8100cf4 fix: P0 安全加固 + 前端错误边界 + ESLint 修复
后端:
- 实现登出 API(清除 refresh token)
- 清除 videos.py 中已被 Celery 任务取代的死代码
- 添加速率限制中间件(60次/分钟,登录10次/分钟)
- 添加 SECRET_KEY/ENCRYPTION_KEY 默认值警告
- OSS STS 方法回退到 Policy 签名(不再抛异常)

前端:
- 添加全局 404/error/loading 页面
- 添加三端 error.tsx + loading.tsx 错误边界
- 修复 useId 条件调用违反 Hooks 规则
- 修复未转义引号和 Image 命名冲突
- 添加 ESLint 配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:18:04 +08:00
Your NameandClaude Opus 4.6 a8be7bbca9 feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用:
- 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API
- 添加 loading 骨架屏、error toast 提示、submitting 状态管理
- 数据映射:TaskResponse → 页面视图模型,处理类型差异
- 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo
- Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等
- 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:29:43 +08:00
Your NameandClaude Opus 4.6 54eaa54966 feat: 前端全面对接后端 API(Phase 1 完成)
- 新增基础设施:useOSSUpload Hook、SSEContext Provider、taskStageMapper 工具
- 达人端4页面:任务列表/详情/脚本上传/视频上传对接真实 API
- 代理商端3页面:工作台/审核队列/审核详情对接真实 API
- 品牌方端4页面:项目列表/创建项目/项目详情/Brief配置对接真实 API
- 保留 USE_MOCK 开关,mock 模式下使用类型安全的 mock 数据
- 所有页面添加 loading 骨架屏、SSE 实时更新、错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:47 +08:00
Your NameandClaude Opus 4.6 4a3c7e7923 refactor: 清理无用模块、修复前后端对齐、添加注册页面
- 删除后端 risk_exceptions 模块(API/Model/Schema/迁移/测试)
- 删除后端 metrics 模块(API/测试)
- 删除后端 ManualTask 模型和相关 Schema
- 修复搜索接口响应缺少 total 字段的问题
- 统一 Platform 枚举(前端去掉后端不支持的 weibo/wechat)
- 新增前端注册页面 /register,登录页添加注册链接

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:51:17 +08:00
Your NameandClaude Opus 4.6 a32102f583 feat: 补全后端 API 并对齐前后端类型
- 后端新增: Project CRUD / Brief CRUD / 组织关系管理 / 工作台统计 / SSE 推送 / 认证依赖注入
- 后端完善: 任务 API 全流程(创建/审核/申诉) + Task Service + Task Schema
- 前端修复: login 页面 localStorage key 错误 (miaosi_auth -> miaosi_user)
- 前端对齐: types/task.ts 与后端 TaskStage/TaskResponse 完全对齐
- 前端新增: project/brief/organization/dashboard 类型定义
- 前端补全: api.ts 新增 30+ API 方法覆盖所有后端接口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:13:08 +08:00
Your NameandClaude Opus 4.5 23835ee790 feat: 前端对接后端认证 API
API 客户端更新:
- 添加双 Token JWT 认证支持
- 添加 401 自动刷新 Token 机制
- 添加注册/登录/刷新 Token API
- 添加 OSS 上传凭证 API

AuthContext 更新:
- 支持真实 API 认证
- 保留开发模式 Mock 数据
- 添加 register 方法

类型定义更新:
- User 类型添加组织关联字段
- 添加 LoginCredentials、RegisterData 类型

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 13:49:07 +08:00
Your NameandClaude Opus 4.5 4caafdb50f feat: 添加后端核心模块
用户认证:
- User 模型(支持邮箱/手机号登录)
- 双 Token JWT 认证(accessToken + refreshToken)
- 注册/登录/刷新 Token API

组织模型:
- Brand(品牌方)、Agency(代理商)、Creator(达人)
- 多对多关系:品牌方↔代理商、代理商↔达人

项目与任务:
- Project 模型(品牌方发布)
- Task 模型(完整审核流程追踪)
- Brief 模型(解析后的结构化内容)

文件上传:
- 阿里云 OSS 直传签名服务
- 支持分片上传,最大 500MB

数据库迁移:
- 003_user_org_project_task.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 13:47:36 +08:00
Your NameandClaude Opus 4.5 4b8809efe2 docs: 完善项目约定文档
- 认证:双 Token JWT 方案,支持邮箱/手机号登录
- 组织:多对多关系(品牌方↔代理商↔达人)
- 文件:阿里云 OSS 直传,分片上传,最大 500MB
- AI:全部走中转服务商
- 审核:终审默认开启,代理商默认有强制通过权
- 技术:PostgreSQL + Redis + SSE

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 13:38:12 +08:00
Your NameandClaude Opus 4.5 3deaa7dcaa fix: 修正 AI 服务配置说明
- 明确使用 OneAPI/OneInAll 等中转服务商,不直连 AI 厂商
- 创建 CONVENTIONS.md 记录项目约定,避免误解

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 12:52:13 +08:00
Your NameandClaude Opus 4.5 37ac749071 fix: 修复前端代码质量问题
- 创建 Toast 通知组件,替换所有 alert() 调用
- 修复 useReview hook 内存泄漏(setInterval 清理)
- 移除所有 console.error 和 console.log 语句
- 为复制操作失败添加用户友好的 toast 提示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 12:48:22 +08:00
Your NameandClaude Opus 4.5 a5a005db0c feat: 完善审核台文件预览与消息通知系统
主要更新:
- 新增 FilePreview 通用组件,支持视频/图片/PDF 内嵌预览
- 审核详情页添加文件信息卡片、预览/下载功能
- 审核列表和详情页添加申诉标识和申诉理由显示
- 完善三端消息通知系统(达人/代理商/品牌)
- 新增达人 Brief 查看页面
- 新增品牌方消息中心页面
- 创建后端开发备忘文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 12:20:47 +08:00
Your NameandClaude Opus 4.5 bbc8a4f641 feat: 添加 AI 服务状态监控和警告功能
- AI 配置页面添加服务健康状态显示(正常/降级/异常)
- 服务异常时显示红色警告卡片,展示错误信息和队列任务数
- 侧边栏 AI 配置入口添加红点警告徽章支持
- DesktopLayout 支持传递 aiServiceError 状态

AI 调用风险处理方案:
- 自动重试 3 次
- 失败后加入队列,后台定时重试
- 页面显示服务状态警告

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:37:20 +08:00
Your NameandClaude Opus 4.5 b83d7e068c chore: 移除调试日志
- 移除下载功能中的 console.log 调试代码
- 改为 TODO 注释标记待实现

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:57:46 +08:00
Your NameandClaude Opus 4.5 0bfedb95c8 feat: 为所有终端添加平台显示功能
- 新增 frontend/lib/platforms.ts 共享平台配置模块
- 支持6个平台: 抖音、小红书、B站、快手、微博、微信视频号
- 品牌方终端: 项目看板、项目详情、终审台列表添加平台显示
- 代理商终端: 工作台概览、审核台、Brief配置、达人管理、
  数据报表、消息中心、申诉处理添加平台显示
- 达人端: 任务列表添加平台显示
- 统一使用彩色头部条样式展示平台信息

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:53:51 +08:00
Your NameandClaude Opus 4.5 964797d2e9 feat: 完善品牌方和代理商前端功能
品牌方功能:
- 项目看板: 添加截止日期编辑功能
- 项目详情: 添加代理商管理、截止日期编辑、最近任务显示代理商
- 项目创建: 代理商选择支持搜索(名称/ID/公司名)
- 代理商管理: 通过ID邀请、添加备注/分配项目/移除操作
- Brief配置: 新增项目级Brief和规则配置页面
- 系统设置: 完善账户安全(密码/2FA/邮箱/手机/设备管理)、数据导出、退出登录

代理商功能:
- 个人中心: 新增代理商ID展示、公司信息(企业验证)、个人信息编辑
- 账户设置: 密码修改、手机/邮箱绑定、两步验证
- 通知设置: 分类型和渠道的通知开关
- 审核历史: 搜索筛选和统计展示
- 帮助反馈: FAQ分类搜索和客服联系

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 17:40:11 +08:00
Your NameandClaude Opus 4.5 ae74c515c7 style(agency): 导出报表按钮改为主色调更醒目
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:42:04 +08:00
Your NameandClaude Opus 4.5 dbf9de66a9 feat(agency): 数据报表添加时间切换和导出功能
时间范围切换:
- 本周:按每天显示趋势,对比上周
- 本月:按每周显示趋势,对比上月
- 本季度:按每月显示趋势,对比上季度
- 本年:按每月显示全年趋势,对比去年

导出功能:
- 支持 Excel、CSV、PDF 三种格式
- CSV 格式实现实际下载功能
- 导出内容包含核心指标、趋势数据、项目统计
- 导出成功显示完成提示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:39:18 +08:00
Your NameandClaude Opus 4.5 9f15709eed feat(agency): 达人管理添加操作菜单功能
操作下拉菜单包含:
- 添加/编辑备注:可为达人添加备注信息,列表中显示备注标签和内容
- 分配到项目:选择项目将达人分配进去
- 移除达人:确认后移除该达人(保留历史数据)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:34:53 +08:00
Your NameandClaude Opus 4.5 9a0e2cac03 feat(agency): 达人管理添加任务进度展开提示
- 达人名称下方添加可点击的提示文字
- 未展开时显示"查看 N 个任务进度"
- 展开后显示"收起任务进度"
- 提示用户可以点击查看任务详情

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:31:11 +08:00
Your NameandClaude Opus 4.5 ad45da5286 feat(agency): 优化达人管理页面
- 邀请达人改为使用达人ID(CR+6位数字格式)
- 达人列表新增达人ID列,支持复制
- 每个达人的任务显示当前进度阶段:
  - 脚本阶段:待提交/AI审核/代理商审核/品牌方终审
  - 视频阶段:待提交/AI审核/代理商审核/品牌方终审/已完成
- 展开任务可查看申诉次数并增加配额
- 搜索支持按达人名称或达人ID搜索

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:26:58 +08:00
Your NameandClaude Opus 4.5 66748d4f19 feat(agency): 优化审核台列表页并新增申诉处理功能
审核台列表页优化:
- 每个任务项显示文件名、文件大小
- 添加下载按钮支持文件下载
- 点击"审核"按钮才跳转到详情页
- 按风险等级显示不同颜色的状态标签和按钮

新增申诉处理功能:
- 申诉列表页:展示所有达人申诉,支持搜索和状态筛选
- 申诉详情页:查看申诉内容、附件、原审核问题
- 处理决策面板:通过/驳回申诉并填写处理意见
- 侧边栏添加"申诉处理"菜单入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:54:53 +08:00
Your NameandClaude Opus 4.5 576c89c8c4 feat(agency): 工作台统计数据分类显示脚本和视频
- 待审核数量分脚本/视频显示
- 今日通过数量分脚本/视频显示
- 进行中数量分脚本/视频显示
- 项目概览审核中状态分脚本审核/视频审核显示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:47:43 +08:00
Your NameandClaude Opus 4.5 4753626e5a feat: 完成代理商/品牌方前端及文档更新
代理商端前端:
- 新增达人管理页面(含任务申诉次数管理)
- 新增消息中心(含申诉次数申请审批)
- 新增 Brief 管理(列表、详情)
- 新增审核中心(脚本审核、视频审核)
- 新增数据报表页面

品牌方端前端:
- 优化首页仪表盘布局
- 新增项目管理(列表、详情、创建)
- 新增代理商管理页面
- 新增审核中心(脚本终审、视频终审)
- 新增系统设置页面

文档更新:
- 申诉次数改为按任务分配(每任务初始1次)
- 更新 PRD、FeatureSummary、User_Role_Interfaces 等文档
- 更新 UI 设计规范和开发计划

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:39:23 +08:00
Your NameandClaude Opus 4.5 2f9b7f05fd feat(creator): 完成达人端前端页面开发
- 新增申诉中心页面(列表、详情、新建申诉)
- 新增申诉次数管理页面(按任务显示配额,支持向代理商申请)
- 新增个人中心页面(达人ID复制、菜单导航)
- 新增个人信息编辑、账户设置、消息通知设置页面
- 新增帮助中心和历史记录页面
- 新增脚本提交和视频提交页面
- 优化消息中心页面(消息详情跳转)
- 优化任务详情页面布局和交互
- 更新 ResponsiveLayout、Sidebar、ReviewSteps 通用组件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:38:01 +08:00
Your NameandClaude Opus 4.5 1011e73643 feat(design): 优化创建项目页面代理商选择区域
- 将代理商选择从卡片式改为紧凑列表式
- 添加搜索框支持按名称或ID搜索代理商
- 显示已选数量徽章
- 列表项包含复选框、头像、名称和ID
- 支持滚动浏览大量代理商

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:29:50 +08:00
Your NameandClaude Opus 4.5 dc37fdacc4 feat: 创建项目改为完整页面并添加系统设置到侧边栏
设计更新:
- 创建项目从弹窗改为独立完整页面 (fP5rY)
- 品牌方端侧边栏统一为6个菜单项:
  1. 数据看板
  2. 代理商管理
  3. AI配置
  4. 终审台
  5. 创建项目
  6. 系统设置

创建项目页面包含:
- 项目名称输入框
- 截止日期选择器
- Brief文件上传区(支持拖拽)
- 代理商卡片多选列表
- 取消/创建项目按钮

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 20:20:10 +08:00
Your NameandClaude Opus 4.5 3a1504c5f2 design: 更新品牌方端侧边栏导航并添加创建项目弹窗
设计更新:
- 统一所有品牌方端页面侧边栏: 数据看板、代理商管理、AI配置、终审台、创建项目
- 新增 "品牌方端 - 创建项目弹窗" (M6nJ0)
  - 项目名称输入框
  - 截止日期选择器
  - Brief文件上传区
  - 代理商多选列表(支持选择多个已添加的代理商)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 20:12:02 +08:00
Your NameandClaude Opus 4.5 521d1a1487 feat: 更新品牌方端菜单结构并添加创建项目功能
菜单更新:
- 统一品牌方端侧边栏菜单为: 数据看板、代理商管理、AI配置、终审台
- 添加 [+ 创建项目] 按钮

创建项目功能 (F-54):
- 项目名称输入
- 截止日期选择
- 品牌Brief文件上传 (PDF/Word/Excel/PPT)
- 选择代理商 (可多选已添加的代理商)

设计稿更新:
- 更新所有品牌方端页面的侧边栏导航
- 新增 "品牌方端 - 创建项目弹窗" (M6nJ0)

文档更新:
- User_Role_Interfaces.md: 添加 4.3 创建项目章节
- FeatureSummary.md: 添加 3.8 项目管理章节和 F-54 功能

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 20:10:08 +08:00
Your NameandClaude Opus 4.5 952e7d8dd0 docs: 移除审计日志和舆情预警功能
- 从 User_Role_Interfaces.md 删除 4.4 审计日志和 4.6 舆情预警中心章节
- 更新品牌方端侧边栏和移动端导航,移除相关菜单项
- 从 FeatureSummary.md 删除 3.8 审计日志与证据导出和 3.9 舆情预警中心
- 从 P1/P2 功能列表和角色-功能映射表移除相关功能
- 更新 PRD.md 中审计日志相关表述为操作记录
- 更新 UIDesign.md 和 UIDesignSpec.md 功能清单和页面映射
- 更新 frontend 代码中的文案引用

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 20:04:44 +08:00
Your NameandClaude Opus 4.5 e4959d584f feat: 完善代理商端业务逻辑与前后端框架
主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:27:31 +08:00
263 changed files with 113766 additions and 27348 deletions
+53
View File
@@ -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*
+94
View File
@@ -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
+138
View File
@@ -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/ 自定义 HooksuseOSSUpload 等)
├── 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 双 Tokenaccess 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
View File
@@ -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 等) |
---
## 其他约定
(后续添加)
+24 -7
View File
@@ -11,9 +11,9 @@
| 文档类型 | **Development Plan (技术架构与实施计划)** |
| --- | --- |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.6 |
| **日期** | 2026-02-03 |
| **依据** | FeatureSummary V1.4, PRD V1.0, RequirementsDoc V1.0 |
| **版本号** | V1.7 |
| **日期** | 2026-02-05 |
| **依据** | FeatureSummary V1.7, PRD V1.0, User_Role_Interfaces V1.6 |
| **侧重** | 技术选型、架构设计、MVP 范围、开发排期、验收标准 |
---
@@ -30,6 +30,7 @@
| 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),与两阶段审核流程对齐 |
---
@@ -400,8 +401,8 @@ 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 |
@@ -497,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 样本)。
+204 -120
View File
@@ -3,8 +3,8 @@
| 文档类型 | **Feature Summary (产品功能文档)** |
| --- | --- |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.6 |
| **发布日期** | 2026-02-03 |
| **版本号** | V1.7 |
| **发布日期** | 2026-02-05 |
| **关联文档** | RequirementsDoc.md, PRD.md, User_Role_Interfaces.md |
| **侧重** | 功能清单、优先级、验收标准、界面映射、边界说明 |
@@ -21,6 +21,7 @@
| 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 修订意见采纳情况:**
@@ -81,8 +82,8 @@
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 数据看板 │ │ 规则配置 │ │ 审计日志 │ │
│ │ │ │ 舆情预警 │ │ 证据导出 │ │
│ │ 数据看板 │ │ 规则配置 │ │ 证据导出 │ │
│ │ │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
@@ -204,7 +205,15 @@
**核心价值:** 避免拍完重拍的巨大沉没成本
**界面映射** 达人端 → 智能上传页
**关键功能**
- 标题与任务信息:任务名、平台、截止时间、当前步骤(脚本)
- 文件上传(支持 PDF/Word/纯文本/Excel
- 关键提示:脚本提交后进入 AI 预审,结果回到任务详情
- 提交校验:空内容禁止提交
- 草稿保存:支持本地或后端草稿
- 等待代理商审核态(脚本已通过):任务详情展示当前阶段、进度条高亮、脚本提交信息、AI 结果摘要与消息中心提醒
**界面映射:** 达人端 → 任务详情页(上传区)
---
@@ -217,7 +226,7 @@
- 原内容:"全网第一"
- AI建议:建议改为"深受喜爱"或"销量领先"
**界面映射:** 达人端 → 审核结果 → 修改清单
**界面映射:** 达人端 → 任务详情-审核结果 → 修改清单
---
@@ -231,7 +240,7 @@
**为什么是 P0** 如果 MVP 版本把"我**最**开心的一天"误判为广告法极限词违规,达人会认为这个 AI 是"人工智障",导致口碑崩盘。这是用户体验的底线。
**界面映射:** 达人端 → 审核结果
**界面映射:** 达人端 → 任务详情-审核结果
---
@@ -259,7 +268,7 @@
- 分辨率支持 1080p
- 格式支持 MP4/MOV
**界面映射:** 达人端 → 智能上传页
**界面映射:** 达人端 → 任务详情页(上传区)
---
@@ -299,7 +308,7 @@
**界面映射:**
- 代理商端 → 审核决策台 → 智能进度条
- 达人端 → 审核结果 → 时间轴跳转
- 达人端 → 任务详情-审核结果 → 时间轴跳转
---
@@ -349,7 +358,7 @@
**功能描述:** 在等待期间显示 AI 处理进度。
**展示示例:**
- 🔍 正在解析 Brief 核心卖点...
- 🔍 正在加载任务规则...
- 👁️ 正在逐帧检测竞品 Logo...
- 🧠 正在分析口播情感色彩...
@@ -357,7 +366,7 @@
**为什么是 P0** 视频上传+审核通常需要 3-5 分钟。如果 MVP 只有一个旋转的"Loading"图标而没有具体的文字进度,用户会以为死机了而关闭页面,导致用户流失。
**界面映射:** 达人端 → 智能上传页 → 透明思考 UI
**界面映射:** 达人端 → 任务详情页(上传区) → 透明思考 UI
---
@@ -365,7 +374,7 @@
**功能描述:** 审核完成后提供带时间戳的修改清单。
**界面映射:** 达人端 → 审核结果 → 修改清单
**界面映射:** 达人端 → 任务详情-审核结果 → 修改清单
---
@@ -398,7 +407,7 @@
**功能描述:** 审核员只需点击确认或驳回,无需从头看视频。
**操作说明:**
- 驳回:自动将勾选的问题打包发送给达人
- 驳回:自动将勾选的问题打包发送给达人;任务回到「脚本上传」阶段并触发脚本 AI 预审,再进入代理商复审 →(可选)品牌终审(可循环)
- 通过:
- 若品牌方**未开启终审**(默认)→ 流程结束,任务状态变为「已通过」
- 若品牌方**已开启终审** → 进入品牌方终审队列,任务状态变为「待终审」
@@ -425,7 +434,9 @@
│ 终审开启 │
│ 达人提交 → AI审核 → 代理商初审通过 → 品牌方终审 │
│ ├─ 通过 → ✅ 最终通过 │
│ └─ 驳回 → 返回达人修改
│ └─ 驳回 → 返回脚本上传
│ ↘ │
│ 脚本AI预审 → 代理商复审 →(可选)终审 │
└─────────────────────────────────────────────────────────────┘
```
@@ -439,48 +450,96 @@
**功能描述:** **全角色(达人、代理商、品牌方)** 均可在移动端和桌面端实时查看内容的完整审核流程状态,清晰了解当前处于哪个审核阶段。
**审核状态流转:**
> **重要:** 每个任务包含「脚本阶段」和「视频阶段」两轮审核,进度条需体现当前所处阶段
**两阶段审核流程:**
```
┌─────────────────────────────────────────────────────────────────┐
│ 审核流程状态(全角色可见)
两阶段审核流程(脚本阶段 + 视频阶段)
├─────────────────────────────────────────────────────────────────┤
│ │
① 已提交 ② AI审核中 ③ 待代理商审核 ④ 待品牌终审
⬇️ ⬇️ ⬇️ ⬇️
┌──────┐ ┌──────────┐ ──────────┐ ┌──────────┐
│ │ 📤 │ ──▶ │ 🤖 AI │ ──▶│ 👥 代理商│ ──▶│ 🛡️ 品牌方│
│已上传 │ │ 审核中 │ │ 审核中 │ │ 终审中
└──────┘ └──────────┘ └──────────┘ └──────────┘
⚠️ 需修改 ⚠️ 驳回 ⚠️ 驳回
│ │
└───────────────┴───────────────┘
│ 返回修改 │
【脚本阶段】
┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 📝 │ ──▶│ 🤖 AI ──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│
│ │脚本上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │
└──────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │
❌ 不通过 ❌ 驳回 ❌ 驳回
│ │ │
└───────────┴──────────────┴──────────────┘
↑ 重新提交脚本
│ │
最终状态:✅ 审核通过 或 ❌ 审核驳回
【脚本通过 → 进入视频阶段】
│ │
│ 【视频阶段】 │
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 📹 │ ──▶│ 🤖 AI │──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│ │
│ │视频上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │ │
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ❌ 不通过 ❌ 驳回 ❌ 驳回 │
│ │ │ │ │ │
│ └───────────┴──────────────┴──────────────┘ │
│ ↑ 重新上传视频(无需重新提交脚本) │
│ │
│ 最终状态:✅ 审核通过 → 当日00:00后自动归入历史记录 │
└─────────────────────────────────────────────────────────────────┘
```
**状态定义**
| 状态 | 图标 | 颜色 | 说明 |
| --- | --- | --- | --- |
| 已提交 | 📤 | 灰色 | 内容已上传,等待处理 |
| AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析内容 |
| AI审核通过 | ✅ | 绿色 | AI 未发现硬性问题,进入人工复核 |
| 需修改 | ⚠️ | 橙色 | AI 发现问题,需要达人修改 |
| 待代理商审核 | 👥 | 紫色 | 等待代理商人工复核 |
| 待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审(仅当终审开启时) |
| 审核通过 | ✅ | 绿色 | 流程完成,可发布 |
| 审核驳回 | ❌ | 红色 | 被驳回,需修改后重新提交 |
**任务列表按钮状态逻辑**
| 当前状态 | 任务卡片按钮 | 点击行为 |
| --- | --- | --- |
| 脚本通过(首次) | [查看详情] | 进入结果页,显示「下一步:上传视频」 |
| 脚本通过(已查看) | [上传视频] | 直接进入视频上传页 |
| 视频通过 | [查看结果] | 进入结果页,显示「审核通过,可发布」 |
| 脚本/视频驳回 | [查看修改意见] | 进入结果页查看驳回原因 |
**结果页按钮逻辑:**
| 阶段 | 审核结果 | 按钮文案 |
| --- | --- | --- |
| 脚本阶段 | 通过 | [下一步:上传视频] |
| 脚本阶段 | 驳回 | [重新提交脚本] |
| 视频阶段 | 通过 | [审核通过,可发布] |
| 视频阶段 | 驳回 | [重新上传视频] |
**状态定义(两阶段审核):**
**脚本阶段状态:**
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
| --- | --- | --- | --- | --- |
| 待提交脚本 | 📝 | 灰色 | 等待达人上传脚本 | [上传脚本] |
| 脚本AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析脚本 | [审核中...] |
| 脚本需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
| 脚本待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
| 脚本代理商驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
| 脚本待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 脚本品牌方驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
| 脚本通过 | ✅ | 绿色 | 脚本通过,待上传视频 | [查看详情] 或 [上传视频]* |
> *首次显示 [查看详情],进入结果页点击「下一步:上传视频」后变为 [上传视频]
**视频阶段状态:**
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
| --- | --- | --- | --- | --- |
| 待上传视频 | 📹 | 灰色 | 等待达人上传视频 | [上传视频] |
| 视频AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析视频 | [审核中...] |
| 视频需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
| 视频待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
| 视频代理商驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
| 视频待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 视频品牌方驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
| 审核通过 | ✅ | 绿色 | 视频通过,可发布 | [查看结果] |
| 已归档 | 📁 | 灰色 | 当日00:00后自动归档 | [查看记录] |
**为什么是 P0** 达人最关心"我的内容现在在哪个环节",清晰的流程状态能减少达人焦虑,避免频繁询问代理商,提升用户体验。代理商和品牌方也需要在审核时清楚了解内容当前所处阶段。
**界面映射:**
| 角色 | 端 | 页面 | 说明 |
| --- | --- | --- | --- |
| 达人 | 桌面 | 任务列表、审核结果 | 卡片状态标签 + 顶部进度条 |
| 达人 | 移动 | 任务列表、审核结果 | 卡片状态标签 + 顶部进度条 |
| 达人 | 桌面 | 任务列表、任务详情-审核结果 | 卡片状态标签 + 顶部进度条 |
| 达人 | 移动 | 任务列表、任务详情-审核结果 | 卡片状态标签 + 顶部进度条 |
| 代理商 | 桌面 | 审核决策台 | 顶部进度条,标注"当前:代理商审核" |
| 代理商 | 移动 | 快捷审核 | 导航栏下方进度条 |
| 品牌方 | 桌面 | 终审台 | 顶部进度条,标注"当前:品牌终审" |
@@ -495,7 +554,7 @@
**约束条件:**
- 必须填写放行原因
- 弹窗提供“保存为特例”勾选项(勾选后生成豁免条款,需品牌方确认)
- 记录审批人与操作时间,纳入审计日志
- 记录审批人与操作时间
**界面映射:** 代理商端 → 审核决策台 → 决策栏 → [强制通过]
@@ -530,32 +589,69 @@
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
| 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次)/ 拒绝 / 不处理(忽略)
- **增加上限:** 无上限
**界面映射:**
- 代理商端 → 消息中心 → 申诉次数请求通知
- 代理商端 → 达人管理 → 达人详情 → 任务申诉次数管理
---
@@ -697,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 闭环学习
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
@@ -889,13 +977,14 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| 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 为合并展示)**
| 功能编号 | 功能名称 | 模块 | 备注 |
| --- | --- | --- | --- |
@@ -910,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 新增 |
@@ -924,7 +1011,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| F-30~32 | 批量上传/审核/导出 | 批量处理 | F-30 改为多文件拖拽 |
| F-28 | 版本差异报告 | 版本比对 | |
| F-29 | 双屏同步播放 | 版本比对 | |
| F-41~42 | 舆情监控与案例库 | 舆情 | |
| F-46 | 负样本清洗与回流 | AI 闭环 | ⭐ 新增,让 AI 真正学习 |
---
@@ -943,8 +1029,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| 数据看板 | 个人进度 | 项目/达人 | 全局 |
| 规则配置 | ❌ | ❌ | ✅ |
| 代理商管理 | ❌ | ❌ | ✅ |
| 审计日志 | ❌ | 所管辖 | 全部 |
| 舆情预警 | ❌ | ❌ | ✅ |
---
+65 -29
View File
@@ -131,10 +131,10 @@
- **舆情风控雷达:** 针对"油腻感"、"价值观风险"、"错别字"的专项检测
- **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面
- **移动端支持:** 响应式 H5 覆盖达人/代理商/品牌方(可作为小程序 WebView 承载)
- **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程
- **信用与申诉体系:** 包含按任务独立的申诉次数管理和人工仲裁流程
- **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置
- **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离
- **审计日志与报告导出:** 支持导出可追溯的审核证据链
- **证据链导出:** 支持导出可追溯的审核证据链报告
- **数据看板与核心指标:** 提供核心指标卡片与基础数据概览
### 5.2 Out of Scope
@@ -177,9 +177,11 @@
### 6.2 脚本预审 (Pre-production) [US-03, US-04]
**P0**
- 支持文本脚本提交与预审
- 支持脚本文档上传与预审(PDF/Word/纯文本/Excel
- 输出违规项、遗漏卖点、建议修改
- 帮助达人在拍摄前发现问题,避免拍完重拍的沉没成本
- 脚本提交后进入 AI 预审,结果回到任务详情
- 空内容禁止提交,支持草稿保存(本地或后端)
**P0**
- 语境理解降低误报(区分广告语境与日常语境)
@@ -222,7 +224,8 @@
- 支持确认/驳回操作,无需从头看视频
- **可配置审核流程(F-51)**:品牌方可开启/关闭终审环节
- **终审关闭(默认)**:代理商初审通过 → 最终通过
- **终审开启**:代理商初审通过 → 品牌方终审 → 最终通过/驳回
- **终审开启**:代理商初审通过 → 品牌方终审 → 通过驳回则回到脚本上传
- **驳回回路**(代理商/品牌方):驳回后任务回到「脚本上传」阶段 → 触发脚本 AI 预审 → 代理商复审 →(如开启)品牌终审;未通过则重复循环
- 支持配置终审范围(全部/仅舆情风险/指定代理商)
- 支持配置终审超时处理(默认48小时)
@@ -234,7 +237,8 @@
**验收要点**
- 每条结论包含规则版本、模型版本、证据截图/片段与时间戳
- 终审开启时,代理商通过后任务状态变为「待终审」,品牌方操作后变为「已通过」或「待修改」
- 终审开启时,代理商通过后任务状态变为「待终审」
- 品牌方驳回或代理商驳回时,任务回到「脚本上传」阶段并重新进入 AI → 代理商 →(可选)品牌的复核流程
### 6.5 代理商管理
@@ -251,9 +255,11 @@
### 6.6 申诉与仲裁
**P1**
- 申诉令牌管理与工单流转
- 按任务独立的申诉次数管理(每任务初始1次)
- 达人申请增加申诉次数流程
- 代理商处理申诉次数请求(同意/拒绝)
- 人工仲裁流程与记录
- 审计日志完整可追溯
- 操作记录完整可追溯
### 6.7 版本差异与批量处理 [US-11, US-13]
@@ -297,30 +303,60 @@
### 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次**,不同任务独立计算
- 申诉次数不足时,可向代理商申请增加(无需理由)
- 代理商可在消息中心或达人管理页面处理申请(同意/拒绝/忽略)
---
@@ -330,7 +366,7 @@
| --- | --- | --- |
| 品牌方(含品牌方管理员) | 品牌内任务与规则 | 强制通过、规则管理、报告导出、私有规则配置、AI 服务商配置与管理 |
| 代理商 | 代理商管理范围 | 任务创建、审核确认/驳回、批量处理、人工仲裁、强制通过(按代理商授权,默认开启,可关闭) |
| 达人 | 自己的任务 | 上传脚本/视频、查看报告、申诉 |
| 达人 | 自己的任务 | 在任务详情上传脚本/视频、查看报告、申诉 |
---
@@ -343,14 +379,14 @@
- **规则集**:平台规则 + 品牌私有规则 + 白名单 + 规则版本记录
- **审核记录**:风险项、时间戳、证据片段、风险等级(红/黄/绿)
- **人工决策**:确认/驳回/强制通过 + 操作人 + 操作时间
- **申诉记录**:申诉原因、仲裁结论、令牌消耗
- **申诉记录**:申诉原因、仲裁结论、申诉次数变化(任务维度)
### 9.2 审计要求 [US-12]
- 全流程日志可追溯、不可篡改
- 导出报告包含规则版本、模型版本、证据截图/片段与时间戳
- 支持争议场景下完整审核证据链导出
- 审计日志采用 append-only + hash chain(前序哈希 + 当前内容)确保可追溯
- 操作日志采用 append-only + hash chain(前序哈希 + 当前内容)确保可追溯
---
+2 -2
View File
@@ -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,7 +131,7 @@
3. **分区执法逻辑:** 智能区分“广告段”与“剧情段”,应用不同的审核尺度。
4. **舆情风控雷达:** 针对“油腻感”、“价值观风险”、“错别字”的专项检测模型。
5. **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面。
6. **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程。
6. **信用与申诉体系:** 包含按任务独立的申诉次数管理(每任务初始1次,可向代理商申请增加)和人工仲裁流程。
7. **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置。
8. **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离。
9. **审计日志与报告导出:** 支持导出可追溯的审核证据链。
+28 -21
View File
@@ -421,7 +421,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
├─────────────────────────────────────────────────────────────────┤
│ │
│ 🏠 📤 🔔 👤 │
│ 任务 上传 消息 我的 │
任务 消息 我的 │
│ │
│ ━━━━━━ ──── ──── ──── │
│ (选中态) (3) │
@@ -470,11 +470,11 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
└─────────────────────────────────────────────────────────────────┘
```
### 7.4 智能上传 (透明思考 UI)
### 7.4 任务详情页 - 上传 (透明思考 UI)
```
┌─────────────────────────────────────────────────────────────────┐
│ ◀ 返回 上传视频
│ ◀ 返回 任务详情 · 上传
├─────────────────────────────────────────────────────────────────┤
│ │
│ ╭─────────────╮ │
@@ -494,7 +494,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │
│ 👁️ 正在逐帧检测竞品 Logo... │
│ │
│ ✅ Brief 解析完成 00:05 │
│ ✅ 任务规则加载完成 00:05 │
│ ✅ ASR 语音转写完成 00:23 │
│ ◐ Logo 检测中... 进行中 │
│ ○ 语义分析 等待中 │
@@ -521,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 任务详情-审核结果区
```
┌─────────────────────────────────────────────────────────────────┐
@@ -584,12 +591,21 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
└─────────────────────────────────────────────────────────────────┘
```
**等待代理商审核态(脚本已通过)显示:**
- 任务信息头部:任务名、平台、截止时间、当前阶段(“等待代理商审核”)
- 审核流程进度条:当前阶段高亮,已完成阶段打勾
- 脚本提交信息:文件名/类型(PDF/Word/纯文本/Excel)、提交时间
- AI 脚本预审结果摘要:结论通过、简短说明、软性提示(Warn-only)以提示样式展示
- 等待提示:显示“已进入代理商审核,请耐心等待”
- 结果告知:提示后续结果将在消息中心提醒
**设计要点:**
- 顶部结果横幅用语义色背景,一目了然
- 视频进度条上标注问题时间点(红/黄点)
- 点击时间点可跳转视频对应位置
- 每条问题提供"跳转"和"申诉"两个操作
- 软性提示明确标注"不影响通过"
- 代理商/品牌方驳回:结果横幅显示“未通过”,主操作为“重新上传脚本”;任务回到脚本上传并触发脚本 AI 预审 → 代理商复审 →(可选)品牌终审(可循环)
### 7.6 申诉弹窗
@@ -624,8 +640,8 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │ │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ │ │
│ │ 💡 剩余申诉令牌:2 次 │ │
│ │ 申诉成功后令牌将自动返还 │ │
│ │ 💡 本任务剩余申诉次数:1 次 │ │
│ │ 每个任务独立计算,次数不足可向代理商申请增加 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ 提交申诉 │ │ │
@@ -801,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 油腻风险 (仅提示) │ │
│ │ │ │ 达人表情过于夸张 │ │
│ │ │ │ │ │
@@ -830,11 +839,10 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
**设计要点:**
- 左侧 60%:视频播放器 + 智能进度条(红/黄/绿点)
- 右侧 40%:AI 检查单,分为"硬性合规"、"Brief 完成度"、"舆情雷达"
- 右侧 40%:AI 检查单,分为"硬性合规"、"舆情雷达"
- 底部固定操作栏,三个决策按钮
- 品牌方**按代理商**关闭授权时,“强制通过”按钮改为“申请强制通过”,点击弹出原因并提交审批
- 强制通过弹窗包含“保存为特例”勾选项(默认不勾选),勾选后生成豁免条款并等待品牌方确认
- Brief 参考图可悬浮在视频角落对比
### 8.6 版本比对视窗
@@ -1423,8 +1431,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
| 角色 | 页面名称 | 优先级 | 设计状态 |
| --- | --- | --- | --- |
| **达人** | 任务列表 | P0 | 待设计 |
| | 智能上传 (透明思考 UI) | P0 | 待设计 |
| | 审核结果 | P0 | 待设计 |
| | 任务详情上传 (透明思考 UI) | P0 | 待设计 |
| | 任务详情-审核结果 | P0 | 待设计 |
| | 申诉弹窗 | P1 | 待设计 |
| | 消息中心 | P1 | 待设计 |
| | 历史记录 | P2 | 待设计 |
@@ -1437,9 +1445,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
| **品牌方** | 数据看板 | P0 | 待设计 |
| | 规则配置 | P0 | 待设计 |
| | AI 服务配置 | P0 | 已设计 |
| | 审计日志 | P1 | 待设计 |
| | 代理商管理 | P1 | 待设计 |
| | 舆情预警 | P2 | 设计 |
| | 终审台 | P0 | 设计 |
---
+135 -43
View File
@@ -4,7 +4,7 @@
| --- | --- |
| **项目名称** | 秒思智能审核平台 (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 | **完善品牌方端和代理商端页面清单**:更新侧边栏导航顺序;新增规则配置、脚本终审(预览模式)等页面;补充页面跳转关系图 |
+474 -210
View File
@@ -3,8 +3,8 @@
| 文档类型 | **UI/UX Spec (Interface Definitions)** |
| --- | --- |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.5 |
| **发布日期** | 2026-02-03 |
| **版本号** | V1.8 |
| **发布日期** | 2026-02-06 |
| **关联文档** | RequirementsDoc.md, PRD.md, FeatureSummary.md, DevelopmentPlan.md, AIProviderConfig.md, UIDesign.md, tasks.md |
| **侧重** | 角色权限、核心页面布局、交互逻辑 |
@@ -19,8 +19,11 @@
| V1.1 | 2026-02-02 | Claude | 与 RD/PRD 对齐:补充用户故事引用、区域合规、特例记录规范、证据链权限 |
| V1.2 | 2026-02-02 | Claude | 新增代理商端和品牌方端移动端 UI 设计(工作台、快捷审核、预警、审批) |
| V1.3 | 2026-02-02 | Claude | 新增 AI 服务配置章节(4.6),品牌方专属功能 |
| V1.4 | 2026-02-03 | Claude | **新增审核流程进度可视化 UI(F-52)**:达人端任务列表状态标签、审核结果进度条 |
| V1.4 | 2026-02-03 | Claude | **新增审核流程进度可视化 UI(F-52)**:达人端任务列表状态标签、任务详情-审核结果进度条 |
| V1.5 | 2026-02-03 | Claude | **扩展审核流程进度可视化至全角色**:代理商审核决策台/快捷审核、品牌方审批中心均可查看进度条 |
| V1.6 | 2026-02-05 | Claude | **明确两阶段审核流程**:脚本阶段+视频阶段独立状态;完善任务按钮状态逻辑(查看详情→上传视频);细化结果页按钮(重新提交脚本/重新上传视频);添加历史任务归档规则 |
| V1.7 | 2026-02-06 | Claude | **完善三端导航结构**:品牌方端更新侧边栏导航顺序(项目看板→创建项目→终审台→代理商管理→规则配置→AI配置→系统设置)并新增4.0章节详述页面跳转逻辑;代理商端更新侧边栏导航顺序(工作台→审核台→Brief配置→达人管理→数据报表→消息中心)并补充脚本预览模式跳转;规则配置简化为黑白名单管理;补充所有页面的设计稿节点ID映射 |
| V1.8 | 2026-02-06 | Claude | **申诉次数按任务独立**:每个任务初始1次申诉次数;达人可在个人中心查看各任务申诉次数并申请增加(无需理由);代理商在消息中心/达人管理处理请求(同意/拒绝/忽略,增加次数无上限);新增达人端2.6申诉次数管理页;更新代理商端3.5达人管理增加申诉次数管理功能 |
---
@@ -34,17 +37,17 @@
| --- | --- | --- | --- |
| **终端设备** | **Mobile (主) / Desktop** | **Desktop (主) / Mobile (辅)** | **Desktop (主) / Mobile (辅)** |
| **Brief 管理** | 查看任务详情 | ✅ 上传/解析/编辑 Brief | ✅ 全局规则配置 |
| **脚本/视频提交** | ✅ 上传 & 修改 [US-03] | ❌ 不可提交 | ❌ 不可提交 |
| **脚本/视频提交** | ✅ 任务详情内上传 & 修改 [US-03] | ❌ 不可提交 | ❌ 不可提交 |
| **查看 AI 报告** | ✅ 仅查看自己的 [US-07] | ✅ 查看所管辖达人的 | ✅ 查看所有 |
| **审核决策** | ❌ 无权 | ✅ 初审 (驳回/通过) [US-08] | ✅ 终审(可配置)/ 强制通过 [US-09] |
| **申诉功能** | ✅ 发起申诉 (消耗令牌) | ✅ 仲裁申诉 | ❌ 无需申诉 |
| **申诉功能** | ✅ 发起申诉 (消耗任务申诉次数) / 申请增加次数 | ✅ 仲裁申诉 / 管理达人申诉次数 | ❌ 无需申诉 |
| **证据链导出** | ❌ 无权 | ✅ 导出所管辖任务 | ✅ 导出全部 [US-12] |
| **数据看板** | 仅看个人任务进度 | 整体进度 / 达人排名 | 全局合规率 / 舆情风控 |
| **系统配置** | ❌ 无权 | ❌ 无权 | ✅ 规则库/阈值/白名单/区域合规/审核流程 [US-10A/US-10B] |
| **AI 服务配置** | ❌ 无权 | ❌ 无权(继承品牌方配置) | ✅ 配置 AI 提供商/模型/参数 |
| **用户管理** | ❌ 无权 | ✅ 管理所属达人 | ✅ 管理代理商与达人 |
> **审核流程说明:** 品牌方可在系统设置中配置是否开启终审环节。**默认关闭**,代理商初审通过即为最终通过;开启后,代理商初审通过的内容需进入品牌方终审队列。
> **审核流程说明:** 品牌方可在系统设置中配置是否开启终审环节。**默认关闭**,代理商初审通过即为最终通过;开启后,代理商初审通过的内容需进入品牌方终审队列。代理商或品牌方驳回时,任务回到脚本上传阶段并重新进入 AI → 代理商 →(可选)品牌流程(可循环)。
---
@@ -54,10 +57,10 @@
```
┌─────────────────────────────────────┐
│ 底部导航栏 (Tab Bar) │
├─────────┬─────────┬─────────┬───────
│ 🏠 │ 📤 │ 🔔 │ 👤 │
│ 任务 │ 上传 │ 消息 │ 我的 │
└─────────┴─────────┴─────────┴───────
├─────────┬─────────┬───────┤
│ 🏠 │ 🔔 │ 👤 │
│ 任务 │ 消息 │ 我的 │
└─────────┴─────────┴───────┘
```
### 代理商端 (Desktop Sidebar)
@@ -81,31 +84,32 @@
│ 工作台 │ 审核 │ 任务 │ 消息 │ 我的│
└─────────┴─────────┴─────────┴─────────┴─────┘
```
> 📱 **移动端定位:** 外出场景下的紧急审核处理、进度查看、消息通知,复杂配置操作引导至桌面端完成
> 📱 **移动端定位:** 外出场景下的紧急审核处理、进度查看、消息通知,复杂配置操作(如 Brief 配置)引导至桌面端完成
### 品牌方端 (Desktop Sidebar)
```
┌──────────────────────────────────────────────┐
│ 📊 数据看板 (Analytics)
🛡️ 规则配置 (Rule Engine)
📋 审计日志 (Audit Log)
👥 代理商管理
🔔 舆情预警
⚙️ 系统设置
└ 🤖 AI 服务配置
│ 📊 项目看板 (Dashboard) ← 默认首页
创建项目 (Create Project)
🛡️ 终审台 (Final Review)
🏢 代理商管理 (Agency)
📋 规则配置 (Rules) ← 黑白名单
🤖 AI配置 (AI Settings)
⚙️ 系统设置 (Settings)
└──────────────────────────────────────────────┘
```
> **导航说明:** 规则配置为独立入口,包含黑名单/白名单管理;终审台仅当终审开启时显示待审任务
### 品牌方端 (Mobile Tab Bar) 📱
```
┌─────────────────────────────────────────────┐
│ 底部导航栏 (Tab Bar) │
├──────────────────┬──────────────────┬─────
│ 📊 │ 🔔 │ ✅ │ 📋 │ 👤
│ 看板 │ 预警 │ 审批 日志 │ 我的│
└──────────────────┴──────────────────┴─────
├─────────────┬─────────────┬─────────────────┤
│ 📊 │ ✅ │ 👤
│ 看板 │ 审批 │ 我的
└─────────────┴─────────────┴─────────────────┘
```
> 📱 **移动端定位:** 关键指标查看、舆情预警响应、强制通过审批,规则配置等复杂操作引导至桌面端完成
> 📱 **移动端定位:** 关键指标查看、终审审批处理,规则配置等复杂操作引导至桌面端完成
---
@@ -114,10 +118,28 @@
**设计目标:** 极简、透明、减少焦虑。让达人像发朋友圈一样简单地完成合规检查。
**核心设备:** 手机浏览器 (H5) / 小程序。
**达人ID(唯一标识):**
* 每个达人在系统中拥有唯一的**达人ID**(如:`CR123456`
* 达人ID用于代理商精准邀请达人加入合作
* 达人可在个人中心查看并复制自己的达人ID
* 达人ID在注册时自动生成,不可修改
**代理商邀请通知:**
* 代理商通过达人ID搜索并发送邀请
* 达人在消息中心收到邀请,点击「同意」或「拒绝」
* 同意后自动加入该代理商的达人列表,可接收任务分配
### 2.1 任务列表页 (Task List)
* **状态概览:** 卡片式布局,显示当前任务状态(待提交、AI审核中、需修改、已通过)。
* **行动号召 (CTA):** 针对不同状态显示醒目按钮,如 `[上传脚本]``[查看修改意见]`
* **状态概览:** 卡片式布局,显示当前任务状态(待提交脚本、脚本审核中、脚本需修改、待上传视频、视频审核中、视频需修改、已通过)。
* **两阶段审核说明:** 每个任务需经过「脚本阶段」和「视频阶段」两轮审核,每阶段均需通过 AI 审核 → 代理商审核 → (可选)品牌方审核
* **行动号召 (CTA) 与按钮状态逻辑:**
* `[上传脚本]`:任务初始状态,未提交脚本
* `[查看详情]`:**首次**脚本审核通过后显示,点击进入结果页查看通过详情
* `[上传视频]`:在结果页点击「下一步:上传视频」返回任务列表后显示
* `[查看修改意见]`:脚本或视频审核被驳回时显示
* `[查看结果]`:视频审核通过后显示
* **历史任务归档:** 当日结束后(00:00),所有「已通过」状态的任务自动归档至历史记录
#### 2.1.1 审核流程进度可视化 ⭐ F-52
@@ -139,35 +161,77 @@
└─────────────────────────────────────────────────────────────────┘
```
**状态定义与样式:**
| 状态 | 图标 | 颜色 | 标签文案 |
| --- | --- | --- | --- |
| 待提交 | 📤 | 灰色 | 待提交 |
| 已提交 | ✅ | 灰色 | 已提交 |
| AI审核中 | 🤖 | 蓝色(动画) | AI审核中 |
| AI审核通过 | ✅ | 绿色 | AI通过,待人工复核 |
| 需修改 | ⚠️ | 橙色 | 需修改 |
| 待代理商审核 | 👥 | 紫色 | 等待代理商审核 |
| 代理商驳回 | ❌ | 红色 | 代理商驳回 |
| 待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 |
| 品牌方驳回 | ❌ | 红色 | 品牌方驳回 |
| 审核通过 | ✅ | 绿色 | 审核通过,可发布 |
**状态定义与样式(两阶段审核)**
**进度条规则**
- 终审关闭时:显示 4 步(已提交 → AI审核 → 代理商审核 → 通过)
- 终审开启时:显示 5 步(已提交 → AI审核 → 代理商审核 → 品牌终审 → 通过)
**脚本阶段状态**
| 状态 | 图标 | 颜色 | 标签文案 | 任务卡片按钮 |
| --- | --- | --- | --- | --- |
| 待提交脚本 | 📤 | 灰色 | 待提交脚本 | [上传脚本] |
| 脚本AI审核中 | 🤖 | 蓝色(动画) | 脚本AI审核中 | [审核中...] (禁用) |
| 脚本AI审核通过 | ✅ | 绿色 | 脚本AI通过,待人工复核 | [查看详情] |
| 脚本需修改 | ⚠️ | 橙色 | 脚本需修改 | [查看修改意见] |
| 脚本待代理商审核 | 👥 | 紫色 | 等待代理商审核 | [查看详情] |
| 脚本代理商驳回 | ❌ | 红色 | 脚本代理商驳回 | [查看修改意见] |
| 脚本待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 脚本品牌方驳回 | ❌ | 红色 | 脚本品牌方驳回 | [查看修改意见] |
| 脚本审核通过 | ✅ | 绿色 | 脚本通过,待上传视频 | [查看详情] 或 [上传视频]* |
> *按钮状态说明:脚本审核通过后**首次**显示 [查看详情],用户进入结果页点击「下一步:上传视频」返回后,按钮变为 [上传视频]
**视频阶段状态:**
| 状态 | 图标 | 颜色 | 标签文案 | 任务卡片按钮 |
| --- | --- | --- | --- | --- |
| 待上传视频 | 📹 | 灰色 | 待上传视频 | [上传视频] |
| 视频AI审核中 | 🤖 | 蓝色(动画) | 视频AI审核中 | [审核中...] (禁用) |
| 视频AI审核通过 | ✅ | 绿色 | 视频AI通过,待人工复核 | [查看详情] |
| 视频需修改 | ⚠️ | 橙色 | 视频需修改 | [查看修改意见] |
| 视频待代理商审核 | 👥 | 紫色 | 等待代理商审核 | [查看详情] |
| 视频代理商驳回 | ❌ | 红色 | 视频代理商驳回 | [查看修改意见] |
| 视频待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 视频品牌方驳回 | ❌ | 红色 | 视频品牌方驳回 | [查看修改意见] |
| 审核通过 | ✅ | 绿色 | 审核通过,可发布 | [查看结果] |
**历史任务:**
| 状态 | 图标 | 颜色 | 标签文案 | 任务卡片按钮 |
| --- | --- | --- | --- | --- |
| 已完成(历史) | ✅ | 灰色 | 已完成 | [查看记录] |
> 历史任务说明:当日 00:00 后,所有「审核通过」的任务自动归档至历史记录页面
**进度条规则(两阶段审核):**
> 重要:每个任务包含「脚本阶段」和「视频阶段」两轮审核,进度条需体现当前所处阶段
**脚本阶段进度条:**
- 终审关闭时:显示 4 步(脚本已提交 → AI审核 → 代理商审核 → 脚本通过)
- 终审开启时:显示 5 步(脚本已提交 → AI审核 → 代理商审核 → 品牌终审 → 脚本通过)
- 脚本阶段通过后,进度条切换为视频阶段
**视频阶段进度条:**
- 终审关闭时:显示 4 步(视频已提交 → AI审核 → 代理商审核 → 视频通过)
- 终审开启时:显示 5 步(视频已提交 → AI审核 → 代理商审核 → 品牌终审 → 视频通过)
**通用规则:**
- 当前阶段高亮显示,已完成阶段打勾,未到达阶段灰色
- **脚本阶段驳回**:任务回到「待提交脚本」,需重新上传脚本
- **视频阶段驳回**:任务回到「待上传视频」,需重新上传视频(无需重新提交脚本)
### 2.2 智能上传与扫描 (The Magic Scanner) [US-03, US-07]
### 2.2 任务详情 - 上传与扫描 (The Magic Scanner) [US-03, US-07]
这是达人等待 AI 结果的页面,必须缓解等待焦虑(Wait-time Anxiety)。
这是嵌入任务详情页的上传/扫描区域,达人等待 AI 结果必须缓解等待焦虑(Wait-time Anxiety)。
* **文件支持:** 支持粘贴文本、上传文档、上传视频文件(≤ 100MB,1080p
* **标题与任务信息:** 展示任务名、平台、截止时间、当前步骤(脚本/视频
* **脚本提交方式:**
* **文件上传**(支持 PDF/Word/纯文本/Excel
* **关键提示:** 脚本提交后进入 AI 预审,结果回到任务详情
* **提交按钮 + 校验:** 空内容禁止提交
* **草稿保存:** 支持保存草稿(本地或后端)
* **文件支持:** 支持脚本文档上传、视频文件上传(≤ 100MB,1080p
* **透明思考 UI:** 实时显示 AI 处理进度
* 屏幕中央显示 AI 正在扫描的动态波纹
* **进度指示器:** 显示当前处理阶段和预估剩余时间
* **滚动日志 (Rolling Log):** 实时显示 AI 动作,例如:
> 🔍 *正在解析 Brief 核心卖点...*
> 🔍 *正在加载任务规则...*
> 👁️ *正在逐帧检测竞品 Logo...*
> 🧠 *正在分析口播情感色彩...*
> ✅ *口播检测完成,正在核对卖点覆盖...*
@@ -181,6 +245,37 @@
当 AI 发现问题时,不能直接把 JSON 扔给达人,要翻译成"人话"。
> **重要说明:** 审核结果页根据当前所处阶段(脚本/视频)和审核结果(通过/驳回)显示不同内容和按钮
#### 2.3.0 结果页按钮逻辑(核心交互)
| 当前阶段 | 审核结果 | 结果页按钮 | 点击后跳转 |
| --- | --- | --- | --- |
| 脚本阶段 | AI审核通过/代理商审核通过/品牌审核通过 | `[下一步:上传视频]` | 返回任务列表,按钮变为 [上传视频] |
| 脚本阶段 | AI审核不通过 | `[重新提交脚本]` | 进入脚本上传页 |
| 脚本阶段 | 代理商/品牌方驳回 | `[重新提交脚本]` | 进入脚本上传页 |
| 视频阶段 | AI审核通过/代理商审核通过/品牌审核通过 | `[审核通过,可发布]` | 返回任务列表,任务显示「审核通过」 |
| 视频阶段 | AI审核不通过 | `[重新上传视频]` | 进入视频上传页 |
| 视频阶段 | 代理商/品牌方驳回 | `[重新上传视频]` | 进入视频上传页 |
> **按钮状态变化说明:** 脚本阶段通过后,用户**首次**在任务列表看到 [查看详情] 按钮,点击进入结果页后看到 [下一步:上传视频],点击该按钮返回任务列表后,按钮变为 [上传视频],此后点击直接进入视频上传页。
#### 2.3.1 等待代理商审核状态(脚本已通过)
达人在脚本 AI 预审通过后进入该状态,页面内容为只读等待态。
* **任务信息头部:** 任务名、平台、截止时间、当前阶段("脚本阶段 - 等待代理商审核")
* **审核流程进度条:** 脚本阶段进度条,当前阶段高亮,已完成阶段打勾
* **脚本提交信息:**
* 文件名/类型(PDF/Word/纯文本/Excel
* 提交时间
* **AI 脚本预审结果摘要:**
* 结论:通过
* 简短说明(如"无硬性违规")
* 如有软性提示(Warn-only),以提示样式展示,不阻断等待状态
* **等待提示:** "已进入代理商审核,请耐心等待"
* **结果告知:** 后续结果将在消息中心提醒
#### 2.3.1 审核流程进度条 ⭐ F-52
**页面顶部显示完整审核流程进度:**
@@ -210,7 +305,7 @@
**进度条交互:**
- 点击已完成的节点可查看该阶段详情(时间、处理人、结果)
- 当前阶段显示预估等待时间
- 驳回状态时显示驳回原因摘要
- 驳回状态时显示驳回原因摘要,并提示返回脚本上传重新进入审核流程
* **结果横幅:**
* 🔴 **未通过 (Blocked):** 存在硬性违规,必须修改。
@@ -231,22 +326,45 @@
* **申诉入口:**
* 在每一条报错旁边提供 `[ 申诉 ]` 按钮
* **申诉弹窗:**
* 显示剩余令牌数量(如:剩余 2 次)
* 令牌配额基于达人信用评分(历史表现越好,配额越高)
* 显示当前任务剩余申诉次数(如:本任务剩余 1 次)
* 每个任务初始申诉次数为 **1次**,不同任务独立计算
* 提供理由输入框(必填,≥ 10 字)
* 可上传补充证据(截图、链接等)
* **申诉流程:** 提交 → 代理商仲裁 → 结果通知
* **令牌返还:** 申诉成功后令牌自动返还
* **次数不足时:** 显示"申诉次数不足"提示,提供 `[申请增加]` 按钮引导达人向代理商申请
### 2.4 消息通知中心 (Notification Center)
达人需要及时获知任务状态变化,避免反复主动查询。
* **通知类型:**
* 🔔 **任务分配:** "您有一个新任务【XX品牌618推广】,请在 3 天内提交脚本"
***审核通过:** "恭喜!您的视频已通过审核,可安排发布"
* **需要修改** "您的视频有 2 处需修改,点击查看详情"
* **通知类型(按两阶段审核)**
**任务分配:**
* 🔔 **新任务** "您有一个新任务【XX品牌618推广】,请在 3 天内提交脚本"
**脚本阶段通知:**
***脚本AI审核通过:** "您的脚本【XX品牌618推广】AI审核通过,已进入代理商审核"
***脚本代理商审核通过:** "您的脚本【XX品牌618推广】代理商审核通过,请上传视频"
***脚本品牌方审核通过:** "您的脚本【XX品牌618推广】品牌方审核通过,请上传视频"
***脚本AI审核不通过:** "您的脚本【XX品牌618推广】有 2 处需修改,点击查看详情"
***脚本代理商驳回:** "您的脚本【XX品牌618推广】被代理商驳回,请查看修改意见"
***脚本品牌方驳回:** "您的脚本【XX品牌618推广】被品牌方驳回,请查看修改意见"
**视频阶段通知:**
***视频AI审核通过:** "您的视频【XX品牌618推广】AI审核通过,已进入代理商审核"
***视频代理商审核通过:** "您的视频【XX品牌618推广】代理商审核通过,等待品牌方终审"
***视频审核通过(最终):** "恭喜!您的视频【XX品牌618推广】已通过审核,可安排发布"
***视频AI审核不通过:** "您的视频【XX品牌618推广】有问题需修改,点击查看详情"
***视频代理商驳回:** "您的视频【XX品牌618推广】被代理商驳回,请重新上传视频"
***视频品牌方驳回:** "您的视频【XX品牌618推广】被品牌方驳回,请重新上传视频"
**其他通知:**
* 👥 **代理商邀请:** "「星辰传媒」邀请您成为签约达人"
* 操作按钮:`[同意]` `[拒绝]`
* 同意后自动加入该代理商的达人列表,可接收任务分配
* 💬 **申诉结果:** "您的申诉已通过,AI 已学习您的反馈"
***申诉次数增加同意:** "代理商【星辰传媒】已同意增加【XX任务】的申诉次数"
***申诉次数增加拒绝:** "代理商【星辰传媒】拒绝了【XX任务】的申诉次数增加请求"
* **通知渠道:**
* H5 内消息中心(必选)
@@ -255,13 +373,38 @@
### 2.5 历史记录页 (History)
* **任务归档:** 按品牌/时间筛选已完成的任务
* **任务归档规则**
* **自动归档:** 当日 00:00 后,所有「审核通过」状态的任务自动归入历史记录
* **手动归档:** 不支持,系统自动处理
* 筛选维度:按品牌/时间筛选已完成的任务
* **数据统计:**
* 累计完成任务数
* 一次通过率(个人)
* 平均修改轮次
* 一次通过率(个人):脚本一次通过率、视频一次通过率
* 平均修改轮次:脚本平均修改次数、视频平均修改次数
* **证书导出:** 支持导出"合规达人"认证徽章(达到一定通过率后解锁)
### 2.6 申诉次数管理页 (Appeal Quota)
**入口:** 达人端 → 个人中心 → 申诉次数
**功能描述:** 达人查看所有任务的申诉次数,并可向代理商申请增加。
* **任务申诉次数列表:**
* 显示所有进行中任务的申诉次数
* 每条记录包含:任务名称、代理商名称、剩余次数、已使用次数
* 支持按代理商/任务状态筛选
* **申请增加次数:**
* 点击某任务的 `[申请增加]` 按钮
* 无需填写理由,直接发送申请给该任务的代理商
* 申请状态:待处理 / 已同意 / 已拒绝
* **申诉次数规则说明:**
* 每个任务初始申诉次数:**1次**
* 不同任务的申诉次数独立计算
* 代理商可增加的次数无上限
* 申诉次数用完时,无法提交新的申诉
---
## 3. 界面详解:代理商端 (The Agency Console)
@@ -269,32 +412,135 @@
**设计目标:** 高效、批量、上帝视角。
**核心设备:** 桌面端 (Desktop Web) 为主,移动端 (Mobile) 为辅。
**代理商ID(唯一标识):**
* 每个代理商在系统中拥有唯一的**代理商ID**(如:`AG123456`
* 代理商ID用于品牌方精准邀请代理商加入合作
* 代理商可在设置页查看并复制自己的代理商ID
* 代理商ID在注册时自动生成,不可修改
**品牌方邀请通知:**
* 品牌方通过代理商ID搜索并发送邀请
* 代理商在消息中心收到邀请,点击「同意」或「拒绝」
* 同意后自动加入该品牌方的代理商列表,可接收项目分配
### 3.0 页面结构与跳转逻辑
#### 侧边栏导航
```
秒思 (Logo)
├── 工作台 ← 默认首页 (RX8V9)
├── 审核台 → 审核台(列表页) (zjiCT)
├── Brief配置 → Brief配置中心(列表页) (Nicby)
├── 达人管理 → 达人管理页面 (5cFMX)
├── 数据报表 → 数据报表页 (An8gw)
└── 消息中心 → 消息中心页 (PfMR0)
```
#### 页面跳转关系图
**工作台跳转:**
```
工作台 (RX8V9)
├── 我的项目 [查看] ──────────► 项目详情页 (C7wfV)
└── 紧急待办
├── 品牌新任务 [查看详情] ────► Brief配置详情页(待配置) (jRsW5)
├── 脚本审核任务 [审核脚本] ──► 脚本审核决策台 (f8HX9)
├── 视频审核任务 [审核视频] ──► 视频审核决策台 (2u8Bq)
└── 申诉仲裁任务 [仲裁] ──────► 审核决策台(带申诉标签)
```
**审核台跳转:**
```
审核台(列表页) (zjiCT)
├── 脚本审核列表
│ └── 任务 [审核] ──► 脚本审核决策台 (f8HX9)
│ ├── [预览脚本] ──► 预览模式 (Wct5R)
│ └── [通过/驳回] ──► 返回列表
└── 视频审核列表
└── 任务 [审核] ──► 视频审核决策台 (2u8Bq) ──[通过/驳回/强制通过]──► 返回列表
```
**Brief配置跳转:**
```
Brief配置中心(列表页) (Nicby)
├── 待配置列表
│ └── 项目 ──点击──► 详情页(待配置) (jRsW5) ──保存后──► 移到已配置列表
└── 已配置列表
└── 项目 ──点击──► 详情页(已配置) (b06fU) ──可更换文件/编辑规则
```
**项目详情页:**
```
项目详情页 (C7wfV)
├── Brief信息 [下载] [预览]
├── 已分配达人列表(显示各达人当前状态)
└── [+ 分配达人] ──► 弹窗选择达人
```
**达人管理:**
```
达人管理 (5cFMX)
├── 达人列表(显示达人信息、粉丝数、平台、状态)
└── [+ 邀请达人] ──► 邀请达人弹窗 (ADN10)
```
### 3.1 工作台 (Dashboard)
* **待办事项:** 醒目显示 `待人工复核 (12)``申诉待仲裁 (3)`
* **项目概览:** 显示当前 Brief 下的所有达人提交进度条。
* **我的项目:** 显示代理商负责的项目列表
* 每个项目显示:项目名称、达人数量
* 点击 `[查看]` 进入项目详情页
* **紧急待办:** 醒目显示待处理任务
* **品牌新任务**(绿色):品牌方刚分配的新项目,点击 `[查看详情]` 进入项目详情页配置Brief
* **脚本审核**(紫色):点击 `[审核脚本]` 进入脚本审核决策台
* **视频审核**(红色):点击 `[审核视频]` 进入视频审核决策台
* **申诉仲裁**(橙色):点击 `[仲裁]` 进入审核决策台(带申诉标签)
* **统计卡片:** 今日通过数、待审核数等概览数据
### 3.2 Brief 配置中心 (Brief Setup) [US-01, US-02]
* **全能解析器:** 巨大的拖拽上传区域
* 支持 PDF/Word/Excel/PPT/图片上传
> **重要说明:** 项目由品牌方分配给代理商,代理商不能自行创建项目。品牌方分配新项目后,项目会出现在代理商的"待配置列表"中。
#### 3.2.1 Brief配置中心(列表页)
* **待配置列表:** 显示品牌方已分配但尚未配置Brief的项目
* **已配置列表:** 显示已上传Brief并完成规则解析的项目
* 点击项目进入对应的详情页
#### 3.2.2 Brief配置详情页(待配置)
* **左侧面板 - 文档上传区:**
* 拖拽上传区域,支持 PDF/Word/Excel/PPT/图片
* **在线文档链接导入:** 支持飞书/Notion 等已授权分享链接
* ⚠️ **重要约束:** 仅支持用户授权的分享链接;不得绕过权限或抓取受限内容
* **投放平台选择:** 选择目标平台(抖音/小红书/B站等),自动加载对应平台规则库
* **区域合规切换:** 不同地区投放可切换对应法规与平台规则版本
* **规则确认区 (Split View):**
* 左侧:原始 PDF/文档预览
* 右侧:AI 提取出的**结构化规则表单**(可编辑)
* *必含词:* [美白] [淡斑] (支持手动增删)
* *禁忌词:* [药用] [治疗]
* *语义卖点:* [产品核心功效] [使用场景] (支持手动增删,AI 基于语义理解而非关键词匹配)
* *调性标签:* [年轻活力] [专业可信] (支持手动选择/自定义)
* *时序要求:* [产品同框 > 5秒] [品牌名提及 ≥ 3次] (支持手动配置)
* *参考图:* (显示 AI 从 Brief 提取的参考图,支持增删)
* **规则冲突提示:** 若 Brief 要求与平台规则冲突,高亮显示并给出建议
* **投放平台选择:** 选择目标平台(抖音/小红书/B站等),自动加载对应平台规则库
* **右侧面板 - AI提取规则:**
* 必含词、禁忌词、语义卖点、时序要求
* 支持手动编辑和添加
* **操作按钮:** `[取消]` `[保存规则]`
* **保存后:** 项目从"待配置列表"移到"已配置列表"
#### 3.2.3 Brief配置详情页(已配置)
* **左侧面板 - Brief文件:**
* 显示已上传的文件名、大小、上传时间
* 状态标签:「已配置」
* 操作按钮:`[预览]` `[更换文件]`
* **右侧面板 - AI提取规则:**
* 显示已解析的规则内容(必含词、禁忌词、语义卖点、时序要求)
* 支持编辑和修改
* **操作按钮:** `[取消]` `[保存规则]`
> 💡 **软广/种草内容审核说明:** 软性植入内容通常没有明确的关键词,系统通过**语义理解**而非关键词匹配来检测卖点覆盖情况。例如,"产品核心功效"是一个语义概念,AI 会理解达人是否表达了产品的功效,而不是简单搜索某个具体词汇。
### 3.2.4 项目详情页
* **Brief信息:** 显示项目关联的Brief文件
* `[下载Brief]`:下载原始文件
* `[预览Brief]`:弹窗预览文件内容
* **已分配达人列表:** 显示该项目下所有达人及其当前任务状态
* 状态包括:脚本审核中、视频审核中、已通过、待修改等
* **分配达人:** `[+ 分配达人]` 点击弹窗选择达人分配到该项目
@@ -335,24 +581,21 @@
* 🔴 红点:硬伤(点击跳转)
* 🟠 橙点:油腻/舆情风险
* 🟢 绿点:成功识别到的卖点(High-light)
* **画中画参考:** 播放器角落可悬浮 Brief 中的参考图,方便对比(如对比手持产品的手势)
* **右侧:AI 检查单 (The Checklist)**
* **分区一:硬性合规 (Hard Rules)** — 必须处理
* [✅] 违禁词检测
* [✅] 竞品 Logo 检测
* **分区二:Brief 完成度 (Brief Compliance)**
* [❌] 卖点:未提及"24小时持妆" (AI 提示:全程未检测到相关语义)
* **分区三:舆情雷达 (Sentiment Radar)** [US-06]
* **分区二:舆情雷达 (Sentiment Radar)** [US-06]
* [⚠️] **00:42 油腻预警:** 达人表情过于夸张,建议检查
* ⚠️ **重要说明:** 软性风险(油腻/爹味/性别偏见等)**仅作提示,不强制拦截**,需人工复核确认
* **底部:决策栏 (Action Bar)**
* `[ 驳回 ]`:点击后,自动将勾选的问题打包发送给达人
* `[ 驳回 ]`:点击后,自动将勾选的问题打包发送给达人;任务回到脚本上传并触发脚本 AI 预审 → 代理商复审 →(可选)品牌终审
* `[ 强制通过 ]` [US-09]:强制通过(默认可用;品牌方关闭授权时按钮改为"申请强制通过",提交后进入审批)
* **必须填写放行原因**(如"达人玩的新梗,品牌方认可")
* 弹窗提供"**保存为特例**"可选项(**默认不勾选**,勾选后形成豁免条款,需品牌方确认后生效)
* **记录审批人**与操作时间,纳入审计日志
* **记录审批人**与操作时间
* `[ 通过 ]`
* 若品牌方**未开启终审**(默认)→ 流程结束,任务状态「已通过」
* 若品牌方**已开启终审** → 进入品牌方终审队列,任务状态「待终审」
@@ -379,20 +622,43 @@
### 3.5 达人管理 (Creator Management)
> **说明:** 达人管理为单一列表页,用于添加和管理达人信息,无单独的达人详情页。
* **达人列表:**
* 显示所有关联达人的基本信息、信用评分、历史通过率
* 显示所有关联达人的基本信息:昵称、平台账号、粉丝量级
* 显示合作数据:累计任务数、一次通过率、信用评分
* 支持按平台(抖音/小红书/B站)、状态(活跃/休眠)筛选
* **达人画像卡片**
* 基本信息:昵称、平台账号、粉丝量级
* 合作数据:累计任务数、一次通过率、平均响应时长
* 信用评分:基于历史表现的信用分(影响申诉令牌配额)
* **邀请达人:**
* 点击 `[+ 邀请达人]` 打开邀请弹窗
* **达人ID(唯一标识):** 每个达人在系统中拥有唯一的达人ID,用于精准邀请
* **邀请流程:**
1. 代理商在弹窗中输入达人ID进行搜索
2. 系统显示匹配的达人信息(头像、昵称、平台账号)
3. 代理商确认后点击 `[发送邀请]`
4. 达人在消息中心收到邀请通知:"「XX代理商」邀请您成为签约达人"
5. 达人点击 `[同意]` 后,自动加入代理商的达人列表
6. 达人点击 `[拒绝]` 后,邀请取消
* **邀请状态:** 待接受 / 已接受 / 已拒绝
* **批量操作:**
* 批量分配任务
* 批量发送催促通知
* 批量导出达人数据
* **申诉次数管理:**
* 在达人列表中点击某达人,可展开查看该达人的所有任务
* 每个任务显示:任务名称、剩余申诉次数、已使用次数
* 点击 `[+1]` 按钮可直接为该任务增加1次申诉次数
* 支持批量增加申诉次数
* **增加次数无上限**
* **申诉次数请求处理:**
* 当达人申请增加申诉次数时,代理商在消息中心收到通知
* 通知内容:"达人【XXX】申请增加【XXX任务】的申诉次数"
* 点击通知可直接处理:`[同意]` / `[拒绝]`
* 也可在达人管理页面主动为任意任务增加申诉次数
### 3.6 数据报表 (Analytics Reports)
* **项目维度:**
@@ -443,6 +709,7 @@
│ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────┤
│ 📌 紧急待办 │
│ ├─ 🟢 新项目 - 品牌方已分配 (刚刚) │
│ ├─ 🔴 达人A视频 - 竞品露出 (2小时前) │
│ ├─ 🟠 达人B申诉 - 待仲裁 (30分钟前) │
│ └─ 🟡 达人C视频 - AI审核完成 │
@@ -540,12 +807,66 @@
**设计目标:** 监管、配置、数据沉淀、风险预警。
**核心设备:** 桌面端 (Desktop Web) 为主,移动端 (Mobile) 为辅。
### 4.1 数据看板 (Executive Dashboard) ✨ *核心功能*
### 4.0 页面结构与跳转逻辑
品牌方需要一目了然地掌握整体合规状况。
#### 侧边栏导航
```
秒思 (Logo)
├── 项目看板 ← 默认首页
├── 创建项目 → 创建项目页面
├── 终审台 → 终审台(列表页)
├── 代理商管理 → 代理商管理页面
├── 规则配置 → 规则配置页面 (黑白名单)
├── AI配置 → AI服务配置页面
└── 系统设置 → 系统设置页面
```
#### 页面跳转关系图
**项目看板跳转:**
```
项目看板 (xUM9m)
├── 项目卡片 [点击] ──────────► 项目详情数据看板 (D1O6f)
└── [+ 创建项目] ────────────► 创建项目页面 (fP5rY)
```
**终审台跳转:**
```
终审台(列表页) (afJEU)
├── 脚本待审列表
│ └── 任务 [审核] ──► 脚本终审决策台 (Sw2hw) ──[预览脚本]──► 预览模式 (cp5CE)
│ ──[通过/驳回]──► 返回列表
└── 视频待审列表
└── 任务 [审核] ──► 视频终审决策台 (aePi5) ──[通过/驳回]──► 返回列表
```
**代理商管理:**
```
代理商管理 (2jnnO)
├── 代理商列表(显示代理商信息、项目数、状态)
└── [+ 邀请代理商] ──► 邀请代理商弹窗 (GyUlM)
```
**规则配置:**
```
规则配置 (nhHSF)
├── 黑名单管理 ──► 添加/编辑/删除违禁词
└── 白名单管理 ──► 添加/编辑/删除豁免词
```
### 4.1 项目看板 (Project Dashboard) ✨ *核心功能*
**界面映射:** `pencil-new.pen` → "品牌方端 - 项目看板" (xUM9m)
品牌方需要一目了然地掌握整体合规状况和项目进展。
> 对应 PRD 成功指标:人工投入时长、初审通过率、召回率/误报率、舆情一致性
**项目卡片列表:**
* 展示所有营销项目卡片
* 每个卡片显示:项目名称、状态、脚本数/视频数、截止日期
* 点击卡片进入项目详情数据看板
**顶部指标卡片:**
```
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
@@ -568,32 +889,67 @@
* 🟠 **关注:** "达人B连续3次提交未通过,建议沟通"
* 🟡 **舆情:** "本周舆情风险拦截数异常上升,建议检查阈值设置"
### 4.2 全局规则配置 (Rule Engine) [US-10A/US-10B]
### 4.2 规则配置 (Rule Configuration) [US-10A/US-10B]
* **黑白名单管理:**
* **禁用词库:** 支持分类管理(广告法 / 平台规则 / 品牌私有)
* **竞品列表:** 上传竞品 Logo 图库,支持相似度阈值设置
* **白名单:** 允许特定达人/场景豁免某些规则
* **特例记录:** 查看从审核台记录的豁免条款,支持确认/撤销
**界面映射:** `pencil-new.pen` → "品牌方端 - 规则配置" (nhHSF)
* **区域合规配置:**
* 支持按投放地区切换法规版本(中国大陆 / 港澳台 / 海外)
* 不同地区规则库独立管理
* 切换时自动校验现有 Brief 与新规则的兼容性
**入口位置:** 品牌方端侧边栏 → 规则配置
* **舆情阈值设置:**
* 调整 AI 对"油腻"、"性感"、"争议话题"的敏感度
* 支持 High / Medium / Low 三档
* 支持按平台差异化配置(抖音 vs 小红书)
* ⚠️ **提示:** 舆情风险仅作提示,不作为强制拦截依据
规则配置页面提供黑名单和白名单的管理功能。
* **规则版本管理:**
* 规则变更历史可追溯(含变更人、变更时间、变更内容)
* 支持回滚到历史版本
* 变更需审批生效(防止误操作)
* **平台规则同步:** 抖音/小红书规则变更时,系统在 1 工作日内更新并通知
* **黑名单管理:**
* **违禁词列表:** 添加/编辑/删除违禁词
* 支持批量导入导出
* 显示违禁词条数统计
### 4.3 代理商管理 (Agency Management)
* **白名单管理:**
* **豁免词列表:** 添加/编辑/删除豁免词
* 支持批量导入导出
* 显示豁免词条数统计
> **说明:** 其他高级规则配置(如区域合规、舆情阈值)在系统设置中进行配置
### 4.3 创建项目 (Create Project) ⭐ 新增
**入口位置:** 品牌方端侧边栏 → 创建项目
**功能描述:** 品牌方创建新的营销项目,配置项目基本信息并分配给代理商执行。
**创建项目页面字段:**
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| 项目名称 | 文本输入 | 必填,项目的名称标识 |
| 截止日期 | 日期选择 | 必填,项目交付截止时间 |
| 品牌Brief | 文件上传 | 必填,支持 PDF/Word/Excel/PPT 格式,最大 50MB |
| 选择代理商 | 卡片多选 | 必填,从已添加的代理商中选择(可多选,卡片式展示) |
**创建流程:**
1. 品牌方点击侧边栏「创建项目」进入创建项目页面
2. 填写项目名称
3. 选择截止日期
4. 上传品牌Brief文档(支持拖拽上传)
5. 从已添加的代理商卡片中勾选参与的代理商(支持多选)
6. 点击 `[创建项目]` 完成创建
7. 系统向选中的代理商发送项目分配通知
**界面映射:** `pencil-new.pen` → "品牌方端 - 创建项目" (fP5rY)
---
### 4.4 代理商管理 (Agency Management)
* **邀请代理商:**
* 点击 `[+ 邀请代理商]` 打开邀请弹窗
* **代理商ID(唯一标识):** 每个代理商在系统中拥有唯一的代理商ID,用于精准邀请
* **邀请流程:**
1. 品牌方在弹窗中输入代理商ID进行搜索
2. 系统显示匹配的代理商信息(头像、名称、代理商ID)
3. 品牌方确认后点击 `[发送邀请]`
4. 代理商在消息中心收到邀请通知:"「XX品牌」邀请您成为合作代理商"
5. 代理商点击 `[同意]` 后,自动加入品牌方的代理商列表
6. 代理商点击 `[拒绝]` 后,邀请取消
* **邀请状态:** 待接受 / 已接受 / 已拒绝
* **代理商列表:**
* 显示合作代理商及其绑定的品牌项目
@@ -608,27 +964,6 @@
* 代理商月度评分卡
* 问题率对比排名
### 4.4 审计日志 (Audit Log) [US-12]
* **列表视图:** 查看所有审核记录,支持高级筛选
* 按时间范围 / 代理商 / 达人 / 审核结果筛选
* 关键词搜索(搜索视频标题、报错内容)
* **详情页:** 点击任意记录查看完整审核链路
* 原始视频(带时间戳标注)
* AI 检测报告全文
* 人工决策记录(谁在什么时间做了什么操作)
* 申诉记录(如有)
* **证据链导出:** 生成符合法务要求的 PDF 报告,包含:
* **时间戳:** 所有操作的精确时间记录
* **截图:** AI 报错对应的视频截图
* **规则依据:** 触发的规则版本与具体条款
* **审核人:** 操作人身份与电子签名
* **规则版本号:** 审核时使用的规则库版本
* **模型版本号:** AI 检测时使用的模型版本
* 完整操作日志(不可篡改)
### 4.5 终审台 (Final Review) ⭐ 新增
**入口位置:** 品牌方端侧边栏 → 终审台(仅当终审开启时显示)
@@ -677,7 +1012,7 @@
* **布局:** 复用代理商审核决策台布局(视频播放器 + AI 检查单)
* **额外信息:** 显示代理商初审意见和通过理由
* **决策按钮:**
* `[ 驳回 ]`:填写驳回理由,任务状态变为「终审驳回」,返回达人修改
* `[ 驳回 ]`:填写驳回理由,任务状态变为「终审驳回」,返回脚本上传并重新进入 AI → 代理商 →(可选)品牌流程
* `[ 通过 ]`:任务状态变为「已通过」,流程结束
#### 4.5.3 审核流程配置
@@ -723,20 +1058,7 @@
---
### 4.6 舆情预警中心 (Brand Safety Center)
* **实时监控:**
* 近期被 AI 标记为"舆情风险"的视频列表
* 按风险等级排序(高 / 中 / 低)
* **案例库:**
* 历史舆情事件归档
* 作为培训素材供代理商学习
* **预警规则:**
* 配置自动通知规则(如:高风险视频自动 @品牌方)
### 4.7 AI 服务配置 (AI Configuration) 🤖
### 4.6 AI 服务配置 (AI Configuration) 🤖
**入口位置:** 系统设置 → AI 服务配置
**权限控制:** 仅品牌方管理员可配置,代理商和达人自动继承品牌方配置
@@ -850,11 +1172,11 @@
* **API Key 加密存储:** 使用 AES-256-GCM 加密,密钥与数据分离存储
* **传输安全:** 所有 API 请求强制 HTTPS
* **权限隔离:** 仅品牌方管理员可查看/修改 AI 配置,代理商/达人不可见
* **审计日志:** 配置变更记录操作人、时间、变更内容
* **配置变更记录:** 记录操作人、时间、变更内容
### 4.7 移动端界面 (Mobile Portal) 📱
**设计目标:** 随时掌握关键指标、即时响应舆情预警、紧急审批处理。
**设计目标:** 随时掌握关键指标、紧急审批处理。
**核心设备:** 手机浏览器 / 小程序。
**定位:** 数据查看与紧急响应,规则配置等复杂操作引导至桌面端完成。
@@ -882,43 +1204,12 @@
│ │ ╱ ╲ │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 📊 🔔 📋 👤
│ 看板 预警 审批 日志 我的
│ 📊 👤
│ 看板 审批 我的
└─────────────────────────────────────────────┘
```
#### 4.7.2 移动端舆情预警 (Alert Center)
**场景:** 收到舆情预警推送,需快速查看并决策。
```
┌─────────────────────────────────────────────┐
│ 🔔 舆情预警 全部已读 │
├─────────────────────────────────────────────┤
│ 🔴 紧急预警 (2) │
│ ┌─────────────────────────────────────┐ │
│ │ ⚠️ 代理商A - 竞品露出集中爆发 │ │
│ │ 过去24小时内5条视频触发 │ │
│ │ 10分钟前 [查看 >] │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ ⚠️ 达人B - 高风险舆情内容 │ │
│ │ 疑似性别偏见言论 │ │
│ │ 30分钟前 [查看 >] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 🟠 关注事项 (3) │
│ ┌─────────────────────────────────────┐ │
│ │ 📋 达人C连续3次提交未通过 │ │
│ │ 建议与代理商沟通 │ │
│ │ 2小时前 [详情 >] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 📊 🔔 ✅ 📋 👤 │
└─────────────────────────────────────────────┘
```
#### 4.7.3 移动端审批中心 (Approval Center)
#### 4.7.2 移动端审批中心 (Approval Center)
**场景:**
1. 当品牌方关闭授权时,代理商申请"强制通过",品牌方需审批
@@ -958,7 +1249,7 @@
│ │ [ 拒绝 ] [ 通过 ] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 📊 🔔 📋 👤
│ 📊 👤
└─────────────────────────────────────────────┘
```
@@ -972,36 +1263,10 @@
* 点击视频片段可预览关键时间点
* 批准/拒绝需二次确认
* 批准后自动通知代理商和达人
* 审批记录同步至审计日志
* **进度条交互:** 点击已完成节点可查看该阶段详情(处理人、时间、结果)
#### 4.7.4 移动端审计日志 (Audit Quick View)
```
┌─────────────────────────────────────────────┐
│ 📋 审计日志 🔍 搜索 │
├─────────────────────────────────────────────┤
│ 筛选:全部 ▼ 代理商 ▼ 时间 ▼ │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ ✅ XX品牌618推广 - 达人A │ │
│ │ 状态:已通过(强制) │ │
│ │ 审批人:李四 · 今天 14:30 │ │
│ │ [查看详情] [导出证据链] │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ ❌ XX品牌618推广 - 达人B │ │
│ │ 状态:已驳回 │ │
│ │ 原因:竞品Logo露出 │ │
│ │ [查看详情] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 📊 🔔 ✅ 📋 👤 │
└─────────────────────────────────────────────┘
```
**移动端功能边界:**
* ✅ 支持:数据查看、预警响应、审批处理、日志查询、证据链导出
* ✅ 支持:数据查看、审批处理
* ❌ 不支持(引导至桌面端):规则配置、阈值调整、代理商权限管理、复杂报表分析
---
@@ -1123,8 +1388,8 @@
| 角色 | 页面名称 | 优先级 | 备注 |
| --- | --- | --- | --- |
| **达人** | 任务列表 | P0 | MVP |
| | 智能上传 | P0 | MVP |
| | 审核结果 | P0 | MVP |
| | 任务详情上传 | P0 | MVP |
| | 任务详情-审核结果 | P0 | MVP |
| | 消息中心 | P1 | |
| | 历史记录 | P2 | |
| **代理商** | 工作台 | P0 | MVP |
@@ -1136,9 +1401,8 @@
| **品牌方** | 数据看板 | P0 | MVP |
| | 规则配置 | P0 | MVP |
| | AI 服务配置 | P0 | MVP - 品牌方专属 |
| | 审计日志 | P1 | |
| | 代理商管理 | P1 | |
| | 舆情预警 | P2 | |
| | 终审台 | P0 | MVP - 开启终审时可见 |
### 10.2 设计资源
+46
View File
@@ -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
+55
View File
@@ -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
+94
View File
@@ -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(有效期限制)
+58
View File
@@ -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"]
+64
View File
@@ -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
+84
View File
@@ -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()
+26
View File
@@ -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')
+2
View File
@@ -0,0 +1,2 @@
"""秒思智能审核平台后端服务"""
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
"""API 路由模块"""
+314
View File
@@ -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,
}
+391
View File
@@ -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": "已退出登录"}
+476
View File
@@ -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)
+289
View File
@@ -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,
)
+172
View File
@@ -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
+234
View File
@@ -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}"',
},
)
+54
View File
@@ -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}
+97
View File
@@ -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}
+320
View File
@@ -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)}
+173
View File
@@ -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": "密码修改成功"}
+392
View File
@@ -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
+235
View File
@@ -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
+343
View File
@@ -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,
)
+205
View File
@@ -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,
)
+61
View File
@@ -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), # 每小时整点执行
},
},
)
+87
View File
@@ -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()
+108
View File
@@ -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",
]
+36
View File
@@ -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
+92
View File
@@ -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",
}
View File
+132
View File
@@ -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]
+55
View File
@@ -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",
]
+59
View File
@@ -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})>"
+35
View File
@@ -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,
)
+29
View File
@@ -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,
)
+67
View File
@@ -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})>"
+45
View File
@@ -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})>"
+160
View File
@@ -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})>"
+77
View File
@@ -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})>"
+89
View File
@@ -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})>"
+126
View File
@@ -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})>"
+148
View File
@@ -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})>"
+58
View File
@@ -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})>"
+6
View File
@@ -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")
+80
View File
@@ -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})>"
+135
View File
@@ -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:]}"
+152
View File
@@ -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
+79
View File
@@ -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
+29
View File
@@ -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
+87
View File
@@ -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
+77
View File
@@ -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)
+70
View File
@@ -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
+223
View File
@@ -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="软性风控提示")
+69
View File
@@ -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
+172
View File
@@ -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
+54
View File
@@ -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",
]
+338
View File
@@ -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,
)
+188
View File
@@ -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)
+310
View File
@@ -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
+31
View File
@@ -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
+217
View File
@@ -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
+349
View File
@@ -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
+105
View File
@@ -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
+138
View File
@@ -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()
+353
View File
@@ -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
+116
View File
@@ -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
+257
View File
@@ -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
+46
View File
@@ -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
+74
View File
@@ -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
+75
View File
@@ -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
+694
View File
@@ -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
+96
View File
@@ -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()
+248
View File
@@ -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
+318
View File
@@ -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,
}
+427
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
"""后台任务模块"""
from app.celery_app import celery_app
__all__ = ["celery_app"]
+416
View File
@@ -0,0 +1,416 @@
"""
视频审核后台任务
完整的视频审核流程下载 提取帧 ASR 视觉分析 生成报告
"""
import asyncio
import os
from datetime import datetime, timezone
from typing import Optional
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.config import settings
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus
from app.models.rule import ForbiddenWord, Competitor, PlatformRule, RuleStatus
from app.models.ai_config import AIConfig
from app.services.video_download import VideoDownloadService, DownloadResult
from app.services.keyframe import KeyFrameExtractor, ExtractionResult
from app.services.asr import VideoASRService, TranscriptionResult
from app.services.vision import CompetitorLogoDetector, VideoOCRService
from app.services.video_review import VideoReviewService
from app.utils.crypto import decrypt_api_key
# 异步数据库引擎
_async_engine = None
_async_session_factory = None
def get_async_engine():
"""获取异步数据库引擎"""
global _async_engine
if _async_engine is None:
_async_engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=5,
max_overflow=10,
)
return _async_engine
def get_async_session() -> sessionmaker:
"""获取异步会话工厂"""
global _async_session_factory
if _async_session_factory is None:
_async_session_factory = sessionmaker(
get_async_engine(),
class_=AsyncSession,
expire_on_commit=False,
)
return _async_session_factory
async def update_review_progress(
db: AsyncSession,
review_id: str,
progress: int,
current_step: str,
status: Optional[DBTaskStatus] = None,
):
"""更新审核进度"""
result = await db.execute(
select(ReviewTask).where(ReviewTask.id == review_id)
)
task = result.scalar_one_or_none()
if task:
task.progress = progress
task.current_step = current_step
if status:
task.status = status
await db.commit()
async def complete_review(
db: AsyncSession,
review_id: str,
score: int,
summary: str,
violations: list[dict],
status: DBTaskStatus = DBTaskStatus.COMPLETED,
soft_warnings: Optional[list[dict]] = None,
):
"""完成审核"""
result = await db.execute(
select(ReviewTask).where(ReviewTask.id == review_id)
)
task = result.scalar_one_or_none()
if task:
task.status = status
task.progress = 100
task.current_step = "完成"
task.score = score
task.summary = summary
task.violations = violations
if soft_warnings is not None:
task.soft_warnings = soft_warnings
task.completed_at = datetime.now(timezone.utc)
await db.commit()
async def fail_review(
db: AsyncSession,
review_id: str,
error: str,
):
"""审核失败"""
result = await db.execute(
select(ReviewTask).where(ReviewTask.id == review_id)
)
task = result.scalar_one_or_none()
if task:
task.status = DBTaskStatus.FAILED
task.current_step = "失败"
task.summary = f"审核失败: {error}"
await db.commit()
async def get_ai_config(db: AsyncSession, tenant_id: str) -> 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 {
"api_key": decrypt_api_key(config.api_key_encrypted),
"base_url": config.base_url,
"models": config.models,
}
async def get_forbidden_words(db: AsyncSession, tenant_id: str) -> list[str]:
"""获取违禁词列表"""
result = await db.execute(
select(ForbiddenWord.word).where(ForbiddenWord.tenant_id == tenant_id)
)
return [row[0] for row in result.fetchall()]
async def get_competitors(db: AsyncSession, tenant_id: str, brand_id: str) -> list[str]:
"""获取竞品列表"""
result = await db.execute(
select(Competitor.name).where(
Competitor.tenant_id == tenant_id,
Competitor.brand_id == brand_id,
)
)
return [row[0] for row in result.fetchall()]
async def get_platform_forbidden_words(
db: AsyncSession, tenant_id: str, brand_id: str, platform: str,
) -> list[str]:
"""从 DB 获取品牌方在该平台的 active 规则中的违禁词"""
result = await db.execute(
select(PlatformRule).where(
PlatformRule.tenant_id == tenant_id,
PlatformRule.brand_id == brand_id,
PlatformRule.platform == platform,
PlatformRule.status == RuleStatus.ACTIVE.value,
)
)
rule = result.scalar_one_or_none()
if not rule or not rule.parsed_rules:
return []
return rule.parsed_rules.get("forbidden_words", [])
async def process_video_review(
review_id: str,
tenant_id: str,
video_url: str,
brand_id: str,
platform: str,
):
"""
处理视频审核异步核心逻辑
流程
1. 下载视频
2. 提取关键帧
3. ASR 语音转写
4. 视觉分析竞品 Logo 检测
5. OCR 字幕提取
6. 违规检测
7. 生成报告
"""
session_factory = get_async_session()
download_service = VideoDownloadService()
keyframe_extractor = KeyFrameExtractor()
review_service = VideoReviewService()
video_path = None
frames_dir = None
logo_detector = None
ocr_service = None
asr_service = None
async with session_factory() as db:
try:
# 更新状态:处理中
await update_review_progress(
db, review_id, 5, "开始处理",
status=DBTaskStatus.PROCESSING,
)
# 获取 AI 配置
ai_config = await get_ai_config(db, tenant_id)
if not ai_config:
await fail_review(db, review_id, "AI 服务未配置")
return
# 获取规则
forbidden_words = await get_forbidden_words(db, tenant_id)
# 合并平台规则中的违禁词
platform_fw = await get_platform_forbidden_words(db, tenant_id, brand_id, platform)
existing_set = set(forbidden_words)
for w in platform_fw:
if w not in existing_set:
forbidden_words.append(w)
existing_set.add(w)
competitors = await get_competitors(db, tenant_id, brand_id)
# 初始化 AI 服务
api_key = ai_config["api_key"]
base_url = ai_config["base_url"]
models = ai_config["models"]
asr_service = VideoASRService(
api_key=api_key,
base_url=base_url,
model=models.get("audio", "whisper-1"),
)
logo_detector = CompetitorLogoDetector(
api_key=api_key,
base_url=base_url,
model=models.get("vision", "gpt-4o"),
)
ocr_service = VideoOCRService(
api_key=api_key,
base_url=base_url,
model=models.get("vision", "gpt-4o"),
)
# 1. 下载视频
await update_review_progress(db, review_id, 10, "下载视频")
download_result: DownloadResult = await download_service.download(video_url)
if not download_result.success:
await fail_review(db, review_id, f"视频下载失败: {download_result.error}")
return
video_path = download_result.file_path
# 2. 提取关键帧
await update_review_progress(db, review_id, 25, "提取关键帧")
extraction_result: ExtractionResult = await keyframe_extractor.extract_at_intervals(
video_path,
interval_seconds=2.0,
max_frames=30,
)
if not extraction_result.success:
await fail_review(db, review_id, f"关键帧提取失败: {extraction_result.error}")
return
frames_dir = extraction_result.output_dir
frames = extraction_result.frames
all_violations = []
# 3. ASR 语音转写
await update_review_progress(db, review_id, 40, "语音转写")
transcript_result: TranscriptionResult = await asr_service.transcribe_video(video_path)
transcript = []
if transcript_result.success:
transcript = [
{"text": seg.text, "start": seg.start, "end": seg.end}
for seg in transcript_result.segments
]
# 检测口播违禁词
speech_violations = await review_service.detect_forbidden_words_in_speech(
transcript,
forbidden_words,
context_aware=True,
)
all_violations.extend(speech_violations)
# 4. 视觉分析 - 竞品 Logo 检测
await update_review_progress(db, review_id, 60, "检测竞品 Logo")
if competitors and frames:
logo_violations = await logo_detector.detect(frames, competitors)
all_violations.extend(logo_violations)
# 5. OCR 字幕提取
await update_review_progress(db, review_id, 75, "提取字幕")
if frames:
subtitles = await ocr_service.extract_subtitles(frames)
# 检测字幕违禁词
subtitle_violations = await review_service.detect_forbidden_words_in_subtitle(
subtitles,
forbidden_words,
)
all_violations.extend(subtitle_violations)
# 6. 分流 violations / soft_warnings
await update_review_progress(db, review_id, 90, "生成报告")
hard_violations = []
soft_warnings_data = []
for v in all_violations:
v_type = v.get("type", "")
if v_type in ("forbidden_word", "efficacy_claim", "competitor_logo", "brand_safety"):
hard_violations.append(v)
elif v_type in ("duration_short", "mention_missing"):
soft_warnings_data.append({
"code": f"video_{v_type}",
"message": v.get("content", ""),
"action_required": "note",
"blocking": False,
"context": {"suggestion": v.get("suggestion", "")},
})
else:
hard_violations.append(v) # 默认当硬性违规
# 计算分数(仅硬性违规影响分数)
score = review_service.calculate_score(hard_violations)
if not hard_violations:
summary = "视频内容合规,未发现违规项"
if soft_warnings_data:
summary += f"{len(soft_warnings_data)} 条提醒)"
else:
high_count = sum(1 for v in hard_violations if v.get("risk_level") == "high")
summary = f"发现 {len(hard_violations)} 处违规"
if high_count > 0:
summary += f"{high_count} 处高风险)"
# 7. 完成审核
await complete_review(
db,
review_id,
score=score,
summary=summary,
violations=hard_violations,
soft_warnings=soft_warnings_data if soft_warnings_data else None,
)
except Exception as e:
await fail_review(db, review_id, str(e))
finally:
# 清理资源
if video_path:
download_service.cleanup(video_path)
if frames_dir:
keyframe_extractor.cleanup(frames_dir)
if logo_detector:
await logo_detector.close()
if ocr_service:
await ocr_service.close()
@shared_task(
bind=True,
name="app.tasks.review.process_video_review_task",
max_retries=3,
default_retry_delay=60,
)
def process_video_review_task(
self,
review_id: str,
tenant_id: str,
video_url: str,
brand_id: str,
platform: str,
):
"""
视频审核 Celery 任务
Args:
review_id: 审核任务 ID
tenant_id: 租户 ID
video_url: 视频 URL
brand_id: 品牌 ID
platform: 平台
"""
try:
# 运行异步任务
asyncio.run(process_video_review(
review_id=review_id,
tenant_id=tenant_id,
video_url=video_url,
brand_id=brand_id,
platform=platform,
))
except Exception as e:
# 重试
raise self.retry(exc=e)
@shared_task(name="app.tasks.review.cleanup_old_files_task")
def cleanup_old_files_task():
"""清理过期的临时文件"""
from app.services.video_download import get_download_service
service = get_download_service()
deleted = service.cleanup_old_files(max_age_seconds=3600)
return {"deleted_files": deleted}
+9
View File
@@ -0,0 +1,9 @@
"""
工具模块
"""
from app.utils.crypto import encrypt_api_key, decrypt_api_key
__all__ = [
"encrypt_api_key",
"decrypt_api_key",
]

Some files were not shown because too many files have changed in this diff Show More