Compare commits

..
61 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
Your NameandClaude Opus 4.5 d52509d630 docs: 完善 TDD 计划与项目名称统一
主要变更:
- 项目名称统一为"秒思智能审核平台"(替换 SmartAudit)
- 完善 TDD 实施评估与计划 (featuredoc/tdd_plan.md V2.0)
  - 新增项目现状诊断与可行性分析
  - 新增前后端测试策略与工具链配置模板
  - 新增 CI/CD 集成方案与 Codecov 配置说明
  - 标注所有待创建模板文件
- 新增 GitHub 配置脚本 (scripts/setup-github.sh)
  - 自动配置分支保护规则
  - 验证 GitHub CLI 登录状态
- 更新 TASK-005-C 包含分支保护与 Codecov 配置
- 同步更新 F-51/F-52 功能至所有相关文档
- UI 设计 Logo 统一为"秒思"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:08:59 +08:00
Your NameandClaude Opus 4.5 6ca52c87d1 Fix tasks.md: correct UI task counts and add missing references
- Fix P0/P1/P2 task counts (17/10/8, not 14/9/8)
- Add total count row showing 35 UI tasks
- Add relationship explanation between function tasks and UI tasks
- Add missing "关联任务" fields for all UI tasks
- Clarify that desktop UI tasks reuse mobile logic with layout adaptation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:48:55 +08:00
Your NameandClaude Opus 4.5 f166c04422 Add frontend component library and UI development tasks
- Create Tailwind CSS configuration with design tokens from UIDesignSpec
- Create globals.css with CSS variables and component styles
- Add React component library:
  - UI components: Button, Card, Tag, Input, Select, ProgressBar, Modal
  - Navigation: BottomNav, Sidebar, StatusBar
  - Layout: MobileLayout, DesktopLayout
- Add constants for colors, icons, and layout
- Update tasks.md with 31 UI development tasks linked to design node IDs
- Configure package.json, tsconfig.json, and postcss.config.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:44:22 +08:00
275 changed files with 118452 additions and 23679 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
+7 -5
View File
@@ -2,9 +2,9 @@
| 文档类型 | **Technical Design (技术设计文档)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **版本号** | V2.0 |
| **日期** | 2026-02-02 |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V2.1 |
| **日期** | 2026-02-03 |
| **侧重** | AI 服务动态配置、多租户隔离、模型选择 |
---
@@ -15,6 +15,7 @@
| --- | --- | --- | --- |
| V1.0 | 2026-02-02 | Claude | 初稿:AI 厂商动态配置架构设计 |
| V2.0 | 2026-02-02 | Claude | 重构:简化为统一提供商+三模型配置方案 |
| V2.1 | 2026-02-03 | Claude | 文档一致性修订:明确单提供商模式与可切换原则 |
---
@@ -22,7 +23,7 @@
### 1.1 业务需求
SmartAudit 系统需要调用三类 AI 服务完成视频审核:
秒思智能审核平台 系统需要调用三类 AI 服务完成视频审核:
| 服务类型 | 用途 | 示例模型 |
| --- | --- | --- |
@@ -35,6 +36,7 @@ SmartAudit 系统需要调用三类 AI 服务完成视频审核:
| 目标 | 描述 |
| --- | --- |
| **灵活配置** | 品牌方可在后台自由选择 AI 提供商和模型 |
| **单一提供商** | 每租户仅保留一套提供商配置,必要时手动切换 |
| **统一接入** | 支持 OneAPI/OpenRouter 中转,一套配置调用多种模型 |
| **直连支持** | 也支持直连 Anthropic、OpenAI、DeepSeek 等厂商 |
| **多租户隔离** | 不同品牌方使用独立的 AI 配置和配额 |
@@ -693,7 +695,7 @@ def mask_api_key(api_key: str) -> str:
## 7. 界面设计
> 详见 UIDesign.md 第 10 章「AI 服务配置界面
> 详见 User_Role_Interfaces.md 第 4.6 章「AI 服务配置」
### 7.1 界面入口
+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 等) |
---
## 其他约定
(后续添加)
+38 -18
View File
@@ -1,4 +1,4 @@
这是一个基于 `RequirementsDoc.md``FeatureSummary.md` (V1.3) 和 `User_Role_Interfaces.md` 编写的开发计划文档。
这是一个基于 `RequirementsDoc.md``FeatureSummary.md` (V1.4) 和 `User_Role_Interfaces.md` 编写的开发计划文档。
这份文档旨在指导技术团队进行架构设计、选型和排期,重点在于解决**视频处理的高并发/高延迟**、**多模态 AI 的集成**以及**移动端适配**等工程难点。
@@ -10,10 +10,10 @@
| 文档类型 | **Development Plan (技术架构与实施计划)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **版本号** | V1.5 |
| **日期** | 2026-02-03 |
| **依据** | FeatureSummary V1.3, PRD V1.0, RequirementsDoc V1.0 |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.7 |
| **日期** | 2026-02-05 |
| **依据** | FeatureSummary V1.7, PRD V1.0, User_Role_Interfaces V1.6 |
| **侧重** | 技术选型、架构设计、MVP 范围、开发排期、验收标准 |
---
@@ -29,6 +29,8 @@
| V1.3 | 2026-02-03 | Claude | **确立 TDD 为项目核心开发规范**,关联 tdd_plan.md |
| V1.4 | 2026-02-03 | Claude | **新增 AI 厂商动态配置架构**,支持数据库配置、运行时热更新、多租户隔离 |
| V1.5 | 2026-02-03 | Claude | 文档一致性修复:统一加密方案、采样精度、处理时间、选型决策、P0 范围、排期等 |
| V1.6 | 2026-02-03 | Claude | 文档一致性修订:AI 配置单提供商模式、审计日志不可篡改方案、FeatureSummary 版本对齐 |
| V1.7 | 2026-02-05 | Claude | 更新依据文档版本(FeatureSummary V1.7),与两阶段审核流程对齐 |
---
@@ -108,7 +110,7 @@ graph TD
| --- | --- | --- |
| **通用语义 (NLP)** | **豆包 Pro / Qwen-Max / DeepSeek** | 处理 Brief 解析、反讽识别、情感分析 |
| **视觉理解 (VLM)** | **Qwen-VL / 豆包视觉** | 处理复杂场景理解(如:环境脏乱差、具体动作判定);**Brief 图片解析** |
| **语音识别 (ASR)** | **Paraformer (阿里) / SenseVoice** | 高精度中文语音转写,支持时间戳对齐 |
| **语音识别 (ASR)** | **Whisper / Paraformer / SenseVoice** | 通过 AIProviderConfig 配置音频模型,支持时间戳对齐 |
| **文字识别 (OCR)** | **PaddleOCR v4** | 针对中文视频字幕优化,开源免费,轻量级 |
| **版面分析 (Layout)** | **PaddleOCR Layout / LayoutLMv3** | Brief PDF 版面分析,提取图文混排结构 |
| **竞品 Logo 检测** | **Grounding DINO + Vector DB** | ⭐ V1.2 修正:改为向量检索方案,见下方说明 |
@@ -119,11 +121,11 @@ graph TD
>
> **核心特性:**
> - **数据库存储配置:** AI 厂商的 API Key、Base URL 等配置存储在数据库中,而非环境变量
> - **运行时动态加载:** 管理员可在后台配置 AI 厂商,系统运行时动态读取配置初始化客户端
> - **多租户隔离:** 不同品牌方可配置独立的 AI 厂商和配额
> - **运行时动态加载:** 管理员可在后台配置**单一 AI 提供商**,系统运行时动态读取配置初始化客户端
> - **多租户隔离:** 不同品牌方可配置独立的 AI 提供商配置和配额
> - **热更新:** 配置变更即时生效,无需重启服务
> - **故障转移:** 主厂商不可用时自动切换到备用厂商
> - **API Key 加密:** 使用 AES-256-GCM 加密存储敏感信息
> - **可切换:** 管理员可随时更换提供商(OneAPI/OpenRouter 或直连厂商)
>
> **支持的厂商类型:**
> - 国内厂商:DeepSeek、通义千问、豆包、智谱、Moonshot
@@ -252,11 +254,11 @@ sequenceDiagram
## 3. MVP (P0) 开发范围定义
基于 `FeatureSummary.md V1.3`MVP 阶段必须包含的功能:
基于 `FeatureSummary.md V1.4`MVP 阶段必须包含的功能:
### ✅ MVP 包含 (Must Have) - 共 21 个 P0 功能
基于 `FeatureSummary.md V1.3` 第 4.1 章定义:
基于 `FeatureSummary.md V1.4` 第 4.1 章定义:
| 模块 | 功能编号 | 功能名称 | 备注 |
| --- | --- | --- | --- |
@@ -399,20 +401,22 @@ sequenceDiagram
| --- | --- | --- |
| `brands` | 品牌方 | id, name, settings_json |
| `agencies` | 代理商 | id, brand_id, name |
| `creators` | 达人 | id, agency_id, credit_score, appeal_tokens |
| `tasks` | 审核任务 | id, brand_id, agency_id, creator_id, status, platform |
| `creators` | 达人 | id, agency_id, credit_score |
| `tasks` | 审核任务 | id, brand_id, agency_id, creator_id, status, platform, appeal_remaining, appeal_used |
| `briefs` | Brief 规则 | id, task_id, raw_file_url, parsed_rules_json |
| `videos` | 视频文件 | id, task_id, version, file_url, duration |
| `reports` | 审核报告 | id, video_id, ai_result_json, human_decision, created_at |
| `risk_items` | 风险项 | id, report_id, type, level, timestamp_start, timestamp_end, evidence_json |
| `rule_sets` | 规则库 | id, brand_id, platform, version, rules_json |
| `audit_logs` | 审计日志 | id, task_id, operator_id, action, detail_json, created_at |
| `audit_logs` | 审计日志 | id, task_id, operator_id, action, detail_json, created_at, prev_hash, hash |
> **审计日志不可篡改策略:** `audit_logs` 采用 append-only 写入 + hash chain(前序哈希 + 当前内容),禁止更新/删除,支持链式校验。
---
## 8. 验收标准 (Acceptance Criteria)
引用自 `FeatureSummary.md V1.3` 第 9 章,MVP 上线前必须满足:
引用自 `FeatureSummary.md V1.4` 第 9 章,MVP 上线前必须满足:
| 验收项 | 标准 | 测量方式 | 责任方 |
| --- | --- | --- | --- |
@@ -494,14 +498,30 @@ sequenceDiagram
- [ ] H5 端在 iOS/Android/微信内置浏览器通过兼容性测试
- [ ] 安全扫描无高危漏洞
### 9.4 本地测试命令与超时建议
**后端全量测试(单测 + API):**
```
cd backend
./venv/bin/pytest tests -q
```
> 说明:全量测试在本机可能耗时 **3~5 分钟**,执行器/CI 请预留 **≥ 300s** 超时预算。
**后端集成测试(Docker):**
```
cd backend
./venv/bin/pytest tests/test_health_integration.py -q -m integration -vv
```
> 说明:需要本机 Docker 正常运行且当前用户有 docker socket 访问权限。
---
## 10. 下一步行动 (Next Steps)
1. **架构师:** 确认 `Database Schema` (特别是 Brief 规则与审核报告的 JSON 结构)。
2. **UI 设计师:** 优先输出 **"达人端 H5 上传"**(含防锁屏提示)和 **"代理商 PC 审核台"** 的高保真原型。
2. **UI 设计师:** 优先输出 **"达人端任务详情上传"**(含防锁屏提示)和 **"代理商 PC 审核台"** 的高保真原型。
3. **AI 工程师:** 搭建 **Logo 向量检索系统** (Grounding DINO + pgvector),验证相似度匹配效果。
4. **AI 工程师:** 调试 **Brief 解析流水线** (Layout Analysis + VLM),确保能提取 PDF 中的参考图片
4. **AI 工程师:** 调试 **Brief 解析流水线** (Layout Analysis + VLM),确保能提取结构化规则
5. **后端工程师:** 搭建 FastAPI 框架骨架,集成 Celery 异步队列,对接弹性 GPU 服务。
6. **前端工程师:** 验证 Wake Lock API 在 iOS Safari / 微信内置浏览器的兼容性。
7. **QA** 准备 AI 模型测试集(违禁词、Logo、Brief 样本)。
@@ -518,6 +538,6 @@ sequenceDiagram
| User_Role_Interfaces.md | 界面规范 |
| tasks.md | 开发任务清单 |
| **featuredoc/tdd_plan.md** | **TDD 实施计划(核心规范)** |
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.0** |
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.1** |
| 数据字典 | 待编写 |
| API 接口规范 | 待编写 |
+128 -11
View File
@@ -1,6 +1,9 @@
# 设计文档矛盾与模糊点分析 (V1.2)
# 设计文档矛盾与模糊点分析 (V1.7)
本文档用于记录并跟踪 `SmartAudit` 项目各设计文档之间的矛盾点与模糊点。以下条目均已对齐并给出统一结论。
本文档用于记录并跟踪 `秒思智能审核平台` 项目各设计文档之间的矛盾点与模糊点。
**最后检查时间:** 2026-02-03
**检查版本:** PRD V1.0, RequirementsDoc V1.0, FeatureSummary V1.6, DevelopmentPlan V1.6, tasks.md V1.6, UIDesignSpec V1.0, UIDesign V1.1, User_Role_Interfaces V1.5, AIProviderConfig V2.1, tdd_plan V1.0
---
@@ -11,28 +14,32 @@
- **对齐文档:** `DevelopmentPlan.md``User_Role_Interfaces.md`
### 1.2 Logo 检测职责边界 (已澄清)
- **结论:** 视觉模型仅用于画面语义/场景风险分析;竞品 Logo 检测由内置 CV 模型处理,不受配置影响。
- **对齐文档:** `AIProviderConfig.md``UIDesign.md``User_Role_Interfaces.md`
- **结论:** 视觉模型仅用于画面语义/场景风险分析;竞品 Logo 检测由内置 CV 模型Grounding DINO + Vector DB处理,不受配置影响。
- **对齐文档:** `AIProviderConfig.md``UIDesign.md``User_Role_Interfaces.md``DevelopmentPlan.md`
### 1.3 文件上传方案 (已同步)
- **结论:** 批量上传采用 **多文件拖拽并发上传 + Tus 断点续传**,弃用 ZIP。
- **对齐文档:** `PRD.md``RequirementsDoc.md``FeatureSummary.md``DevelopmentPlan.md`
### 1.4 AI 厂商配置范围 (已统一)
- **结论:** 每租户仅配置**单一 AI 提供商**OneAPI/OpenRouter 中转或直连厂商),不做自动故障转移;需要切换由管理员手动更新配置。
- **对齐文档:** `AIProviderConfig.md``PRD.md``RequirementsDoc.md``FeatureSummary.md``DevelopmentPlan.md`
---
## 2. 权限与工作流
### 2.1 强制通过与特例关系 (已统一)
- **结论:** 强制通过弹窗内提供 **保存为特例** 勾选项(默认不勾选);勾选后生成豁免条款并等待品牌方确认生效。
- **结论:** "强制通过"弹窗内提供 **保存为特例** 勾选项(默认不勾选);勾选后生成豁免条款并等待品牌方确认生效。
- **对齐文档:** `PRD.md``UIDesign.md``User_Role_Interfaces.md`
### 2.2 强制通过禁用后的流程 (已统一)
- **结论:** 品牌方关闭授权后,代理商端按钮文案变为申请强制通过,填写理由并提交品牌方审批。
- **结论:** 品牌方关闭授权后,代理商端按钮文案变为"申请强制通过",填写理由并提交品牌方审批。
- **对齐文档:** `PRD.md``UIDesign.md``User_Role_Interfaces.md`
### 2.3 AI 配置可见性 (已统一)
- **结论:** 代理商/达人 **不可见** AI 配置,仅品牌方管理员可查看与修改。
- **对齐文档:** `UIDesign.md``User_Role_Interfaces.md`
- **对齐文档:** `UIDesign.md``User_Role_Interfaces.md``AIProviderConfig.md`
---
@@ -50,14 +57,124 @@
## 4. 文档一致性
### 4.1 TDD 计划文档缺失 (已补齐)
### 4.1 TDD 计划文档 (已补齐)
- **结论:** 新增 `featuredoc/tdd_plan.md`,作为 `tasks.md` 中 TDD 引用的正式文档。
- **对齐文档:** `tasks.md`
- **对齐文档:** `tasks.md``DevelopmentPlan.md`
### 4.2 视频采样率假设 (已同步)
- **结论:** CV 采样率默认 **2fps**,并在该采样率下验证时长统计准确性。
- **对齐文档:** `PRD.md``FeatureSummary.md`
- **对齐文档:** `PRD.md``FeatureSummary.md``DevelopmentPlan.md`
### 4.3 UI 设计风格 (已统一)
- **问题:** 存在两份 UI 设计文档,风格不一致:
- `UIDesign.md`: Apple Human Interface Guidelines **浅色系** (#FFFFFF 为主背景)
- `UIDesignSpec.md`: Apple-style **暗色主题** (#0B0B0E 为页面背景)
- **结论:** 以 `UIDesignSpec.md` 和设计稿 `pencil-new.pen` 为准,采用 **暗色主题**
- **已处理:** 在 `UIDesign.md` 头部添加废弃声明,指向 `UIDesignSpec.md` 为正式规范。
- **对齐文档:** `UIDesign.md``UIDesignSpec.md`
- **备注:** `tasks.md` 中的 UIDesign.md 引用保持不变,因 UIDesign.md 中的设计原则和交互说明仍有参考价值;具体颜色/组件规范以 UIDesignSpec.md 为准。
### 4.4 审计日志不可篡改实现 (已明确)
- **结论:** 采用 append-only + hash chain(前序哈希 + 当前内容)保证审计日志可追溯与不可篡改。
- **对齐文档:** `PRD.md``RequirementsDoc.md``FeatureSummary.md``DevelopmentPlan.md``tasks.md`
---
**当前状态:** 无待决策项。
## 5. 数值与指标一致性 (已验证)
### 5.1 功能优先级数量
| 优先级 | PRD | FeatureSummary | DevelopmentPlan | tasks.md | 状态 |
| --- | --- | --- | --- | --- | --- |
| P0 (MVP) | 12项 (场景级) | 23 | 21 | 23 | ✅ 一致(新增 F-51, F-52 |
| P1 | 4项 (场景级) | 22 | - | 22 | ✅ 一致 |
| P2 | 2项 (场景级) | 8 | - | 8 | ✅ 一致 |
### 5.2 技术指标
| 指标 | PRD | FeatureSummary | DevelopmentPlan | 状态 |
| --- | --- | --- | --- | --- |
| 审核报告产出时间 | ≤ 5 分钟(含排队 ≤ 2 分钟) | ≤ 5 分钟(含排队 ≤ 2 分钟) | ≤ 5 分钟(含排队 ≤ 2 分钟) | ✅ 一致 |
| 竞品 Logo F1 | ≥ 0.85 | ≥ 0.85 | ≥ 0.85 | ✅ 一致 |
| ASR 字错率 | ≤ 10% | ≤ 10% | ≤ 10% | ✅ 一致 |
| OCR 准确率 | ≥ 95% | ≥ 95% | ≥ 95% | ✅ 一致 |
| 语境理解误报率 | ≤ 5% | ≤ 5% | ≤ 5% | ✅ 一致 |
| 时长统计误差 | ≤ 1秒 | ≤ 1秒 | ≤ 1秒 | ✅ 一致 |
| 频次统计准确率 | ≥ 95% | ≥ 95% | ≥ 95% | ✅ 一致 |
| 加密方案 | AES-256-GCM | AES-256-GCM | AES-256-GCM | ✅ 一致 |
| CV 采样率 | 2fps | 2fps | 2fps | ✅ 一致 |
### 5.3 开发周期
| 项目 | DevelopmentPlan | tasks.md | 状态 |
| --- | --- | --- | --- |
| 总周期 | 11 周 | 11 周 | ✅ 一致 |
| Phase 1 | Week 1-2 | Week 1-2 | ✅ 一致 |
| Phase 2 | Week 3-6 (4周) | Week 3-6 (4周) | ✅ 一致 |
| Phase 3 | Week 7-9 | Week 7-9 | ✅ 一致 |
| Phase 4 | Week 10-11 | Week 10-11 | ✅ 一致 |
### 5.4 测试覆盖率要求
| 类型 | DevelopmentPlan | tdd_plan.md | tasks.md | 状态 |
| --- | --- | --- | --- | --- |
| 后端覆盖率 | ≥ 80% | ≥ 80% | ≥ 80% | ✅ 一致 |
| 前端覆盖率 | ≥ 70% | ≥ 70% | ≥ 70% | ✅ 一致 |
---
## 6. 合理性评估 (已验证)
### 6.1 技术方案合理性
-**前后端分离 + AI 微服务化**:适合视频处理的高算力需求
-**FastAPI + Celery + Redis**:Python 生态成熟,适合 AI 集成
-**PostgreSQL + pgvector**:减少架构复杂度,统一向量检索
-**Tus 协议**:解决大文件上传不稳定问题
-**弹性 GPU 集群**:支持自动扩缩容,控制成本
### 6.2 时间估算合理性
-**Phase 2 延长至 4 周**:预留多模态时间戳对齐的工程时间
-**Phase 3 移动端 + 审核台**3 周合理
-**Phase 4 联调验收**2 周合理
### 6.3 功能优先级合理性
-**F-09 语境理解提升至 P0**:避免"人工智障"体验
-**F-17 进度展示提升至 P0**:缓解等待焦虑
-**F-05 拆分为 A/B**MVP 聚焦核心防竞品能力
-**F-45 时长频次校验 P0**:满足 Brief 硬性指标
-**F-47/48/49 AI 配置 P0**AI 服务基础设施
---
## 7. 已完成处理项
### 7.1 UI 设计文档统一 ✅
**状态:** 已完成 (2026-02-03)
**描述:** `UIDesign.md`(浅色系)与 `UIDesignSpec.md`(暗色主题)风格冲突
**决策:**`UIDesignSpec.md``pencil-new.pen` 为准(暗色主题)
**已完成行动:**
1. ✅ 在 `UIDesign.md` 头部添加废弃声明,指向 `UIDesignSpec.md` 为正式规范
2.`tasks.md` 中的引用保持不变(设计原则部分仍有参考价值)
### 7.2 AIProviderConfig 文档引用更新 ✅
**状态:** 已完成 (2026-02-03)
**描述:** `AIProviderConfig.md` 第7章引用了已废弃的 `UIDesign.md`
**已完成行动:**
- ✅ 更新引用为 `User_Role_Interfaces.md` 第 4.6 章
### 7.3 tdd_plan.md 版本号补充 ✅
**状态:** 已完成 (2026-02-03)
**描述:** `tdd_plan.md` 缺少版本号和文档头部元信息
**已完成行动:**
- ✅ 添加文档头部元信息,版本号 V1.0
---
**当前状态:** ✅ 无待决策项。所有文档一致性问题已解决,可开始开发。
---
## 8. 检查历史
| 检查时间 | 版本 | 检查人 | 发现问题 | 处理结果 |
| --- | --- | --- | --- | --- |
| 2026-02-03 | V1.4 | Claude | UI设计风格不一致、版本号引用 | 已统一 |
| 2026-02-03 | V1.5 | Claude | AIProviderConfig引用过时、tdd_plan缺版本号 | 已修复 |
| 2026-02-03 | V1.6 | Claude | 新增 F-51 品牌方终审开关、F-52 审核流程进度可视化 | 已同步至全部文档 |
| 2026-02-03 | V1.7 | Claude | F-52 扩展为全角色(达人/代理商/品牌方)可见 | 已同步:FeatureSummary、tasks.md、User_Role_Interfaces、UI设计 |
+276 -103
View File
@@ -2,9 +2,9 @@
| 文档类型 | **Feature Summary (产品功能文档)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **版本号** | V1.3 |
| **发布日期** | 2026-02-03 |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.7 |
| **发布日期** | 2026-02-05 |
| **关联文档** | RequirementsDoc.md, PRD.md, User_Role_Interfaces.md |
| **侧重** | 功能清单、优先级、验收标准、界面映射、边界说明 |
@@ -18,6 +18,10 @@
| V1.1 | 2026-02-02 | Claude | 根据 Gemini 修订意见调整:补充验收标准、Out of Scope、核心痛点细化 |
| V1.2 | 2026-02-02 | Claude | 根据 Gemini 关键改进意见:优先级调整、功能拆分、新增功能、移动端适配 |
| V1.3 | 2026-02-02 | Claude | **新增 AI 厂商动态配置功能模块 (F-47~F-50)**,支持数据库配置、多租户隔离 |
| V1.4 | 2026-02-03 | Claude | 文档一致性修订:AI 配置单提供商模式、审计日志不可篡改方案、版本号更新 |
| V1.5 | 2026-02-03 | Claude | **新增 F-51 品牌方终审开关、F-52 审核流程进度可视化** |
| V1.6 | 2026-02-03 | Claude | **F-52 扩展为全角色可见**:代理商端(桌面+移动)、品牌方端(桌面+移动)均可查看进度 |
| V1.7 | 2026-02-05 | Claude | **明确两阶段审核流程**:脚本阶段+视频阶段;完善任务按钮状态逻辑(查看详情→上传视频);添加历史任务归档规则(当日00:00自动归档) |
**Gemini 修订意见采纳情况:**
@@ -37,7 +41,7 @@
### 1.1 产品定位
SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定位为**"智能预审员"**,在人工介入前**自动化拦截 80% 的基础错误和合规风险**,将审核流转周期从"天"缩短到"小时"。
秒思智能审核平台 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定位为**"智能预审员"**,在人工介入前**自动化拦截 80% 的基础错误和合规风险**,将审核流转周期从"天"缩短到"小时"。
### 1.2 核心价值
@@ -64,7 +68,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
```
┌─────────────────────────────────────────────────────────────────┐
SmartAudit 功能架构 │
秒思智能审核平台 功能架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
@@ -78,8 +82,8 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 数据看板 │ │ 规则配置 │ │ 审计日志 │ │
│ │ │ │ 舆情预警 │ │ 证据导出 │ │
│ │ 数据看板 │ │ 规则配置 │ │ 证据导出 │ │
│ │ │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
@@ -201,7 +205,15 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**核心价值:** 避免拍完重拍的巨大沉没成本
**界面映射** 达人端 → 智能上传页
**关键功能**
- 标题与任务信息:任务名、平台、截止时间、当前步骤(脚本)
- 文件上传(支持 PDF/Word/纯文本/Excel
- 关键提示:脚本提交后进入 AI 预审,结果回到任务详情
- 提交校验:空内容禁止提交
- 草稿保存:支持本地或后端草稿
- 等待代理商审核态(脚本已通过):任务详情展示当前阶段、进度条高亮、脚本提交信息、AI 结果摘要与消息中心提醒
**界面映射:** 达人端 → 任务详情页(上传区)
---
@@ -214,7 +226,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
- 原内容:"全网第一"
- AI建议:建议改为"深受喜爱"或"销量领先"
**界面映射:** 达人端 → 审核结果 → 修改清单
**界面映射:** 达人端 → 任务详情-审核结果 → 修改清单
---
@@ -228,7 +240,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**为什么是 P0** 如果 MVP 版本把"我**最**开心的一天"误判为广告法极限词违规,达人会认为这个 AI 是"人工智障",导致口碑崩盘。这是用户体验的底线。
**界面映射:** 达人端 → 审核结果
**界面映射:** 达人端 → 任务详情-审核结果
---
@@ -256,7 +268,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
- 分辨率支持 1080p
- 格式支持 MP4/MOV
**界面映射:** 达人端 → 智能上传页
**界面映射:** 达人端 → 任务详情页(上传区)
---
@@ -296,7 +308,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**界面映射:**
- 代理商端 → 审核决策台 → 智能进度条
- 达人端 → 审核结果 → 时间轴跳转
- 达人端 → 任务详情-审核结果 → 时间轴跳转
---
@@ -346,7 +358,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**功能描述:** 在等待期间显示 AI 处理进度。
**展示示例:**
- 🔍 正在解析 Brief 核心卖点...
- 🔍 正在加载任务规则...
- 👁️ 正在逐帧检测竞品 Logo...
- 🧠 正在分析口播情感色彩...
@@ -354,7 +366,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**为什么是 P0** 视频上传+审核通常需要 3-5 分钟。如果 MVP 只有一个旋转的"Loading"图标而没有具体的文字进度,用户会以为死机了而关闭页面,导致用户流失。
**界面映射:** 达人端 → 智能上传页 → 透明思考 UI
**界面映射:** 达人端 → 任务详情页(上传区) → 透明思考 UI
---
@@ -362,7 +374,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**功能描述:** 审核完成后提供带时间戳的修改清单。
**界面映射:** 达人端 → 审核结果 → 修改清单
**界面映射:** 达人端 → 任务详情-审核结果 → 修改清单
---
@@ -372,6 +384,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
| --- | --- | --- | --- | --- |
| F-19 | 风险列表展示 | P0 | US-08 | 代理商 |
| F-20 | 确认/驳回操作 | P0 | US-08 | 代理商 |
| F-51 | 品牌方终审开关 | **P0** | - | 品牌方 |
| F-21 | 强制通过权 | P1 | US-09 | 品牌方(默认授权代理商) |
| F-22 | 特例记录与白名单 | P1 | US-09 | 品牌方 |
| F-23 | 规则依据与证据查看 | P1 | US-08 | 代理商/品牌方 |
@@ -394,13 +407,146 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**功能描述:** 审核员只需点击确认或驳回,无需从头看视频。
**操作说明:**
- 驳回:自动将勾选的问题打包发送给达人
- 通过:流程结束
- 驳回:自动将勾选的问题打包发送给达人;任务回到「脚本上传」阶段并触发脚本 AI 预审,再进入代理商复审 →(可选)品牌终审(可循环)
- 通过:
- 若品牌方**未开启终审**(默认)→ 流程结束,任务状态变为「已通过」
- 若品牌方**已开启终审** → 进入品牌方终审队列,任务状态变为「待终审」
**界面映射:** 代理商端 → 审核决策台 → 决策栏
---
#### F-51 品牌方终审开关 ⭐ P0 (新增)
**功能描述:** 品牌方可配置是否对代理商初审通过的内容进行终审。
**配置选项:**
- **终审开关**:开启/关闭(**默认关闭**)
- **终审范围**:全部内容 / 仅舆情风险内容 / 仅指定代理商
- **终审超时处理**:超时自动通过 / 超时提醒(默认48小时)
**流程说明:**
```
┌─────────────────────────────────────────────────────────────┐
│ 终审关闭(默认) │
│ 达人提交 → AI审核 → 代理商初审通过 → ✅ 最终通过 │
├─────────────────────────────────────────────────────────────┤
│ 终审开启 │
│ 达人提交 → AI审核 → 代理商初审通过 → 品牌方终审 │
│ ├─ 通过 → ✅ 最终通过 │
│ └─ 驳回 → 返回脚本上传 │
│ ↘ │
│ 脚本AI预审 → 代理商复审 →(可选)终审 │
└─────────────────────────────────────────────────────────────┘
```
**为什么是 P0** 审核流程是系统核心逻辑,需在 MVP 阶段确定流程框架,即使默认关闭也需要支持配置能力。
**界面映射:** 品牌方端 → 系统设置 → 审核流程配置
---
#### F-52 审核流程进度可视化 ⭐ P0 (新增)
**功能描述:** **全角色(达人、代理商、品牌方)** 均可在移动端和桌面端实时查看内容的完整审核流程状态,清晰了解当前处于哪个审核阶段。
> **重要:** 每个任务包含「脚本阶段」和「视频阶段」两轮审核,进度条需体现当前所处阶段
**两阶段审核流程:**
```
┌─────────────────────────────────────────────────────────────────┐
│ 两阶段审核流程(脚本阶段 + 视频阶段) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 【脚本阶段】 │
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 📝 │ ──▶│ 🤖 AI │──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│ │
│ │脚本上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │ │
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ❌ 不通过 ❌ 驳回 ❌ 驳回 │
│ │ │ │ │ │
│ └───────────┴──────────────┴──────────────┘ │
│ ↑ 重新提交脚本 │
│ │
│ 【脚本通过 → 进入视频阶段】 │
│ │
│ 【视频阶段】 │
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 📹 │ ──▶│ 🤖 AI │──▶│ 👥 代理商│──▶│ 🛡️ 品牌方│ │
│ │视频上传│ │ 审核中 │ │ 审核中 │ │ 终审中 │ │
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ❌ 不通过 ❌ 驳回 ❌ 驳回 │
│ │ │ │ │ │
│ └───────────┴──────────────┴──────────────┘ │
│ ↑ 重新上传视频(无需重新提交脚本) │
│ │
│ 最终状态:✅ 审核通过 → 当日00:00后自动归入历史记录 │
└─────────────────────────────────────────────────────────────────┘
```
**任务列表按钮状态逻辑:**
| 当前状态 | 任务卡片按钮 | 点击行为 |
| --- | --- | --- |
| 脚本通过(首次) | [查看详情] | 进入结果页,显示「下一步:上传视频」 |
| 脚本通过(已查看) | [上传视频] | 直接进入视频上传页 |
| 视频通过 | [查看结果] | 进入结果页,显示「审核通过,可发布」 |
| 脚本/视频驳回 | [查看修改意见] | 进入结果页查看驳回原因 |
**结果页按钮逻辑:**
| 阶段 | 审核结果 | 按钮文案 |
| --- | --- | --- |
| 脚本阶段 | 通过 | [下一步:上传视频] |
| 脚本阶段 | 驳回 | [重新提交脚本] |
| 视频阶段 | 通过 | [审核通过,可发布] |
| 视频阶段 | 驳回 | [重新上传视频] |
**状态定义(两阶段审核):**
**脚本阶段状态:**
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
| --- | --- | --- | --- | --- |
| 待提交脚本 | 📝 | 灰色 | 等待达人上传脚本 | [上传脚本] |
| 脚本AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析脚本 | [审核中...] |
| 脚本需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
| 脚本待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
| 脚本代理商驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
| 脚本待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 脚本品牌方驳回 | ❌ | 红色 | 被驳回,需重新提交 | [查看修改意见] |
| 脚本通过 | ✅ | 绿色 | 脚本通过,待上传视频 | [查看详情] 或 [上传视频]* |
> *首次显示 [查看详情],进入结果页点击「下一步:上传视频」后变为 [上传视频]
**视频阶段状态:**
| 状态 | 图标 | 颜色 | 说明 | 任务按钮 |
| --- | --- | --- | --- | --- |
| 待上传视频 | 📹 | 灰色 | 等待达人上传视频 | [上传视频] |
| 视频AI审核中 | 🤖 | 蓝色/动画 | AI 正在分析视频 | [审核中...] |
| 视频需修改 | ⚠️ | 橙色 | AI 发现问题 | [查看修改意见] |
| 视频待代理商审核 | 👥 | 紫色 | 等待代理商复核 | [查看详情] |
| 视频代理商驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
| 视频待品牌终审 | 🛡️ | 紫色 | 等待品牌方终审 | [查看详情] |
| 视频品牌方驳回 | ❌ | 红色 | 被驳回,需重新上传 | [查看修改意见] |
| 审核通过 | ✅ | 绿色 | 视频通过,可发布 | [查看结果] |
| 已归档 | 📁 | 灰色 | 当日00:00后自动归档 | [查看记录] |
**为什么是 P0** 达人最关心"我的内容现在在哪个环节",清晰的流程状态能减少达人焦虑,避免频繁询问代理商,提升用户体验。代理商和品牌方也需要在审核时清楚了解内容当前所处阶段。
**界面映射:**
| 角色 | 端 | 页面 | 说明 |
| --- | --- | --- | --- |
| 达人 | 桌面 | 任务列表、任务详情-审核结果区 | 卡片状态标签 + 顶部进度条 |
| 达人 | 移动 | 任务列表、任务详情-审核结果区 | 卡片状态标签 + 顶部进度条 |
| 代理商 | 桌面 | 审核决策台 | 顶部进度条,标注"当前:代理商审核" |
| 代理商 | 移动 | 快捷审核 | 导航栏下方进度条 |
| 品牌方 | 桌面 | 终审台 | 顶部进度条,标注"当前:品牌终审" |
| 品牌方 | 移动 | 审批中心 | 审批项内显示进度条 |
---
#### F-21 强制通过权
**功能描述:** 品牌方可手动放行过于保守的误报(如达人玩的新梗)。**默认授权代理商独立使用强制通过功能**;品牌方可在设置中**按代理商**关闭授权,关闭后代理商需发起审批流程。
@@ -408,7 +554,7 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
**约束条件:**
- 必须填写放行原因
- 弹窗提供“保存为特例”勾选项(勾选后生成豁免条款,需品牌方确认)
- 记录审批人与操作时间,纳入审计日志
- 记录审批人与操作时间
**界面映射:** 代理商端 → 审核决策台 → 决策栏 → [强制通过]
@@ -443,32 +589,69 @@ SmartAudit 是一款**基于多模态大模型的 B2B SaaS 审核工具**,定
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
| F-24 | 发起申诉 | P1 | - | 达人 |
| F-25 | 申诉令牌管理 | P1 | - | 系统 |
| F-25 | 申诉次数管理 | P1 | - | 达人/代理商 |
| F-25-A | 申请增加申诉次数 | P1 | - | 达人 |
| F-25-B | 处理申诉次数请求 | P1 | - | 代理商 |
| F-26 | 人工仲裁 | P1 | - | 代理商 |
| F-27 | 申诉结果通知 | P1 | - | 达人 |
#### F-24 发起申诉
**功能描述:** 达人可对每条报错发起申诉。
**功能描述:** 达人可对每条报错发起申诉,消耗该任务的申诉次数
**操作要求:**
- 提供理由输入框(必填,≥ 10 字)
- 可上传补充证据(截图、链接等)
- 消耗申诉令牌
- 消耗该任务的1次申诉次数
- 若申诉次数为0,提示"申诉次数不足"并引导申请增加
**界面映射:** 达人端 → 审核结果 → [申诉] 按钮
**界面映射:** 达人端 → 任务详情-审核结果 → [申诉] 按钮
---
#### F-25 申诉令牌管理
#### F-25 申诉次数管理
**功能描述:** 基于达人信用评分分配令牌配额,申诉成功后令牌返还
**功能描述:** 每个任务独立拥有申诉次数,初始为1次,达人可向代理商申请增加
**规则说明:**
- 历史表现越好,配额越高
- 申诉成功后令牌自动返还
- 每个任务初始申诉次数:**1次**
- 申诉次数按任务独立计算,不同任务互不影响
- 达人可在个人中心查看所有任务的申诉次数
- 代理商可为达人的任务增加申诉次数(无上限)
**界面映射:** 达人端 → 审核结果页 → 申诉弹窗(显示剩余令牌)
**界面映射:**
- 达人端 → 个人中心 → 申诉次数(查看所有任务的申诉次数列表)
- 达人端 → 任务详情-审核结果区 → 申诉弹窗(显示当前任务剩余次数)
---
#### F-25-A 申请增加申诉次数
**功能描述:** 达人可为特定任务向代理商申请增加申诉次数。
**申请流程:**
1. 达人在个人中心 → 申诉次数页面,点击某任务的 `[申请增加]` 按钮
2. 无需填写理由,直接发送申请
3. 代理商在消息中心收到通知
4. 等待代理商处理(同意/拒绝/忽略)
**界面映射:** 达人端 → 个人中心 → 申诉次数 → [申请增加] 按钮
---
#### F-25-B 处理申诉次数请求
**功能描述:** 代理商处理达人的申诉次数增加请求。
**处理方式:**
- **消息中心入口:** 收到通知"达人【XXX】申请增加【XXX任务】的申诉次数",点击可处理
- **达人管理入口:** 在达人管理页面可主动为任意达人的任务增加申诉次数
- **操作选项:** 同意(+1次)/ 拒绝 / 不处理(忽略)
- **增加上限:** 无上限
**界面映射:**
- 代理商端 → 消息中心 → 申诉次数请求通知
- 代理商端 → 达人管理 → 达人详情 → 任务申诉次数管理
---
@@ -610,95 +793,87 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
---
### 3.8 审计日志与证据导出
### 3.8 项目管理
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
| F-38 | 审核记录查询 | P1 | US-12 | 品牌方 |
| F-39 | 完整审核链路查看 | P1 | US-12 | 品牌方 |
| F-40 | 证据链 PDF 导出 | P1 | US-12 | 品牌方/代理商 |
| F-54 | 创建项目 | P0 | - | 品牌方 |
#### F-38 审核记录查询
#### F-54 创建项目 ⭐ 新增
**功能描述:** 查看所有审核记录,支持高级筛选(时间/代理商/达人/结果)
**功能描述:** 品牌方创建营销项目,配置项目基本信息并分配给代理商执行
**界面映射:** 品牌方端 → 审计日志 → 列表视图
**创建项目字段:**
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| 项目名称 | 文本输入 | 必填,项目的名称标识 |
| 截止日期 | 日期选择 | 必填,项目交付截止时间 |
| 品牌Brief | 文件上传 | 必填,支持 PDF/Word/Excel/PPT 格式,最大 50MB |
| 选择代理商 | 卡片多选 | 必填,从已添加的代理商中选择(卡片式展示,支持多选) |
**核心流程:**
1. 品牌方点击侧边栏「创建项目」进入创建项目页面
2. 填写项目名称、选择截止日期
3. 上传品牌Brief文档(支持拖拽上传)
4. 从已添加的代理商卡片中勾选参与的代理商
5. 点击 `[创建项目]` 完成创建
6. 系统向选中的代理商发送项目分配通知
**为什么是 P0** 项目是整个审核流程的起点,没有项目创建功能,代理商无法接收任务。
**界面映射:** 品牌方端 → 侧边栏 → 创建项目 → 创建项目页面 (fP5rY)
---
#### F-39 完整审核链路查看
**功能描述:** 点击任意记录查看完整审核链路,包含原始视频、AI 报告、人工决策、申诉记录。
**界面映射:** 品牌方端 → 审计日志 → 详情页
---
#### F-40 证据链 PDF 导出
**功能描述:** 生成符合法务要求的 PDF 报告。
**报告内容:**
- 时间戳:所有操作的精确时间记录
- 截图:AI 报错对应的视频截图
- 规则依据:触发的规则版本与具体条款
- 审核人:操作人身份与电子签名
- 规则版本号、模型版本号
- 完整操作日志(不可篡改)
**界面映射:** 品牌方端 → 审计日志 → 证据链导出
---
### 3.9 舆情预警中心
### 3.9 代理商管理
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
| F-41 | 舆情风险视频监控 | P2 | US-06 | 品牌方 |
| F-42 | 舆情案例库 | P2 | - | 品牌方 |
| F-43 | 舆情阈值设置 | P1 | US-10B | 品牌方 |
| F-44 | 代理商ID与邀请 | P0 | - | 品牌方/代理商 |
#### F-41 舆情风险视频监控
#### F-44 代理商ID与邀请
**功能描述:** 近期被 AI 标记为"舆情风险"的视频列表,按风险等级排序
**功能描述:** 每个代理商拥有系统唯一的代理商ID(如 `AG123456`),品牌方通过搜索代理商ID发起邀请,代理商在消息中心接受邀请后加入品牌方代理商列表
**界面映射** 品牌方端 → 舆情预警中心 → 实时监控
**核心流程**
1. 品牌方点击「邀请代理商」,输入代理商ID搜索
2. 系统显示匹配的代理商信息(头像、名称、代理商ID)
3. 品牌方确认后发送邀请
4. 代理商在消息中心收到邀请通知,可选择「同意」或「拒绝」
5. 代理商同意后自动加入品牌方的代理商列表,可接收项目分配
**附加功能:** 管理合作代理商与授权范围(可见 Brief 范围、**按代理商**强制通过授权、申诉仲裁权限、绩效评分卡)。
**界面映射:**
- 品牌方端 → 代理商管理 → 邀请代理商弹窗
- 代理商端 → 消息中心 → 品牌方邀请通知
---
#### F-42 舆情案例库
**功能描述:** 历史舆情事件归档,作为培训素材供代理商学习。
**界面映射:** 品牌方端 → 舆情预警中心 → 案例库
---
#### F-43 舆情阈值设置
**功能描述:** 调整 AI 对"油腻"、"性感"、"争议话题"的敏感度,支持按平台差异化配置。
**重要约束:** 舆情风险仅作提示,不作为强制拦截依据
**界面映射:** 品牌方端 → 规则配置 → 舆情阈值设置
---
### 3.10 代理商管理
### 3.10 达人管理
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
| F-44 | 代理商管理 | P1 | - | 品牌方 |
| F-53 | 达人ID与邀请 | P0 | - | 代理商/达人 |
#### F-44 代理商管理
#### F-53 达人ID与邀请
**功能描述:** 管理合作代理商与授权范围(可见 Brief 范围、**按代理商**强制通过授权、申诉仲裁权限、绩效评分卡)
**功能描述:** 每个达人拥有系统唯一的达人ID(如 `CR123456`),代理商通过搜索达人ID发起邀请,达人在消息中心接受邀请后加入代理商达人列表
**界面映射** 品牌方端 → 代理商管理
**核心流程**
1. 代理商点击「邀请达人」,输入达人ID搜索
2. 系统显示匹配的达人信息(头像、昵称、达人ID)
3. 代理商确认后发送邀请
4. 达人在消息中心收到邀请通知,可选择「同意」或「拒绝」
5. 达人同意后自动加入代理商的达人列表,可接收任务分配
**界面映射:**
- 代理商端 → 达人管理 → 邀请达人弹窗
- 达人端 → 消息中心 → 代理商邀请通知
---
### 3.11 AI 闭环学习 (新增)
### 3.11 AI 闭环学习
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
@@ -706,7 +881,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
---
### 3.12 系统管理 - AI 厂商配置 (V1.4 新增)
### 3.12 系统管理 - AI 厂商配置 (V1.4 修订)
| 功能编号 | 功能名称 | 优先级 | 用户故事 | 使用角色 |
| --- | --- | --- | --- | --- |
@@ -717,13 +892,13 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
#### F-47 AI 厂商动态配置 ⭐ P0
**功能描述:** 品牌方管理员可在后台配置多个 AI 厂商(DeepSeek、OpenAI、通义千问、OneAPI 中转等),配置存储在数据库中,运行时动态加载,无需修改代码或重启服务。
**功能描述:** 品牌方管理员可在后台配置**单一 AI 提供商**OneAPI/OpenRouter 中转或直连厂商),配置存储在数据库中,运行时动态加载,无需修改代码或重启服务。每个租户仅保留一套配置,可随时切换提供商。
**核心功能:**
- 支持添加、编辑、删除 AI 厂商配置
- 支持配置与更新 AI 提供商
- 配置 Base URL、API KeyAES-256-GCM 加密存储)、默认模型
- 为不同使用场景(Brief 解析、脚本预审、视频审核)指定不同厂商
- 配置优先级和备用厂商(故障转移)
- 为不同使用场景(Brief 解析、脚本预审、视频审核)指定不同模型
- 可通过 OneAPI/OpenRouter 聚合多模型
- 未配置时阻断调用并提示品牌方完成配置
**为什么是 P0** 这是 AI 服务的基础设施,所有 AI 功能都依赖此配置。
@@ -744,7 +919,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
#### F-49 多租户 AI 配置隔离
**功能描述:** 不同品牌方可配置独立的 AI 商,实现租户级别的配置隔离和配额管理。
**功能描述:** 不同品牌方可配置独立的 AI 提供商,实现租户级别的配置隔离和配额管理。
**界面映射:** 品牌方后台 → 系统设置 → AI 配置
@@ -799,14 +974,17 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| F-17 | 审核进度实时展示 | 视频审核 | ⭐ P1→P0,缓解等待焦虑 |
| F-19 | 风险列表展示 | 审核台 | |
| F-20 | 确认/驳回操作 | 审核台 | |
| F-51 | 品牌方终审开关 | 审核台 | ⭐ 新增,审核流程可配置 |
| F-52 | 审核流程进度可视化 | 达人端 | ⭐ 新增,达人可见审核状态 |
| F-33 | 核心指标卡片 | 数据看板 | |
| F-54 | 创建项目 | 项目管理 | ⭐ 新增,项目是审核流程的起点 |
| F-47 | AI 厂商动态配置 | 系统管理 | ⭐ V1.3 新增,AI 基础设施 |
| F-48 | AI 厂商连通性测试 | 系统管理 | ⭐ V1.3 新增 |
| F-49 | 多租户 AI 配置隔离 | 系统管理 | ⭐ V1.3 新增 |
### 4.2 V1.1 (P1) - 首版后快速迭代
**P1 共 22 个功能(表中区间行如 F-24~27、F-34~36、F-38~40 为合并展示)**
**P1 共 16 个功能(表中区间行如 F-24~27、F-34~36 为合并展示)**
| 功能编号 | 功能名称 | 模块 | 备注 |
| --- | --- | --- | --- |
@@ -821,8 +999,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| F-24~27 | 申诉与仲裁 | 申诉 | |
| F-34~36 | 趋势图表与预警 | 数据看板 | |
| F-37 | 达人排行榜 | 数据报表 | |
| F-38~40 | 审计日志与证据导出 | 审计 | |
| F-43 | 舆情阈值设置 | 舆情 | |
| F-44 | 代理商管理 | 系统管理 | |
| F-50 | API Key 轮换管理 | 系统管理 | ⭐ V1.3 新增 |
@@ -835,7 +1011,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| F-30~32 | 批量上传/审核/导出 | 批量处理 | F-30 改为多文件拖拽 |
| F-28 | 版本差异报告 | 版本比对 | |
| F-29 | 双屏同步播放 | 版本比对 | |
| F-41~42 | 舆情监控与案例库 | 舆情 | |
| F-46 | 负样本清洗与回流 | AI 闭环 | ⭐ 新增,让 AI 真正学习 |
---
@@ -854,8 +1029,6 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| 数据看板 | 个人进度 | 项目/达人 | 全局 |
| 规则配置 | ❌ | ❌ | ✅ |
| 代理商管理 | ❌ | ❌ | ✅ |
| 审计日志 | ❌ | 所管辖 | 全部 |
| 舆情预警 | ❌ | ❌ | ✅ |
---
@@ -868,7 +1041,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| **安全** | 传输与存储加密(AES-256-GCM);基于角色权限控制;关键操作二次确认 |
| **隐私** | 数据最小化;默认保留 30 天;符合《个保法》与 GDPR |
| **数据本地化** | 国内客户数据存储于中国大陆境内服务器 |
| **审计** | 操作日志可审计且不可篡改 |
| **审计** | 操作日志可审计且不可篡改append-only + hash chain |
| **移动端适配** | **达人端(上传/查看报告)必须适配移动端 H5 竖屏操作** |
> ⚠️ **移动端适配说明:** 达人的工作场景多在拍摄现场(移动端),需要在手机上完成脚本上传、查看审核结果等操作。如果只做 PC 网页版,达人无法在拍摄现场即时使用,产品价值会大打折扣。
@@ -925,7 +1098,7 @@ V1 版本指出 3 个违规点:✅ 已修复 2 个 | ❌ 未修复 1 个
| RequirementsDoc.md | 业务需求文档(用户故事、成功指标) |
| PRD.md | 产品需求文档(功能需求、技术架构) |
| User_Role_Interfaces.md | 用户角色与界面规范 |
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.0** |
| **AIProviderConfig.md** | **AI 厂商动态配置架构设计(V2.1** |
| 技术设计文档 (TDD) | 待编写 |
| API 接口规范 | 待编写 |
| 数据字典 | 待编写 |
+92 -31
View File
@@ -2,7 +2,7 @@
| 文档类型 | **PRD (Product Requirement Document)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.0 |
| **发布日期** | 2026-02-03 |
| **状态** | 草稿 (Draft) |
@@ -102,11 +102,12 @@
- 视频自动审核(竞品、违禁词、画面风险) → [US-05]
- 审核台风险打点与确认/驳回 → [US-08]
- 语境理解降低误报 → [US-04]
- 审核进度展示与时间戳修改清单 → [US-07]
- 审核进度展示F-17 → [US-07]
- 基础黑白名单与竞品库(F-05-A → [US-10A]
- 时长与频次校验(F-45 → [US-05]
- AI 服务配置与连通性测试(F-47/F-48)
- 多租户 AI 配置隔离(F-49
- 数据看板核心指标卡片(F-33
**P1(首版发布后快速迭代)**
- Brand Safety 软性风险提示 → [US-06]
@@ -130,10 +131,11 @@
- **舆情风控雷达:** 针对"油腻感"、"价值观风险"、"错别字"的专项检测
- **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面
- **移动端支持:** 响应式 H5 覆盖达人/代理商/品牌方(可作为小程序 WebView 承载)
- **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程
- **信用与申诉体系:** 包含按任务独立的申诉次数管理和人工仲裁流程
- **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置
- **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离
- **审计日志与报告导出:** 支持导出可追溯的审核证据链
- **证据链导出:** 支持导出可追溯的审核证据链报告
- **数据看板与核心指标:** 提供核心指标卡片与基础数据概览
### 5.2 Out of Scope
@@ -175,9 +177,11 @@
### 6.2 脚本预审 (Pre-production) [US-03, US-04]
**P0**
- 支持文本脚本提交与预审
- 支持脚本文档上传与预审(PDF/Word/纯文本/Excel
- 输出违规项、遗漏卖点、建议修改
- 帮助达人在拍摄前发现问题,避免拍完重拍的沉没成本
- 脚本提交后进入 AI 预审,结果回到任务详情
- 空内容禁止提交,支持草稿保存(本地或后端)
**P0**
- 语境理解降低误报(区分广告语境与日常语境)
@@ -218,15 +222,23 @@
**P0**
- 审核台展示风险列表(红/黄/绿分级)与时间戳
- 支持确认/驳回操作,无需从头看视频
- **可配置审核流程(F-51)**:品牌方可开启/关闭终审环节
- **终审关闭(默认)**:代理商初审通过 → 最终通过
- **终审开启**:代理商初审通过 → 品牌方终审 → 通过;驳回则回到脚本上传
- **驳回回路**(代理商/品牌方):驳回后任务回到「脚本上传」阶段 → 触发脚本 AI 预审 → 代理商复审 →(如开启)品牌终审;未通过则重复循环
- 支持配置终审范围(全部/仅舆情风险/指定代理商)
- 支持配置终审超时处理(默认48小时)
**P1**
- 品牌方"强制通过权":可手动放行过于保守的误报(需记录原因与审批人);**默认授权代理商独立使用,可在品牌方设置中按代理商关闭,关闭后需走审批流程**。强制通过弹窗需填写原因,并提供保存为特例可选项(**默认不勾选**,勾选后形成豁免条款,需品牌方确认生效)
- 品牌方"强制通过权":可手动放行过于保守的误报(需记录原因与审批人);**默认授权代理商独立使用,可在品牌方设置中按代理商关闭,关闭后需走审批流程**。强制通过弹窗需填写原因,并提供"保存为特例"可选项(**默认不勾选**,勾选后形成豁免条款,需品牌方确认生效)
- 特例可沉淀为规则白名单/豁免条款(含来源:强制通过勾选或手动记录)
- 如需用于模型优化,必须确保数据授权与合规评估
- 可查看规则依据与证据片段
**验收要点**
- 每条结论包含规则版本、模型版本、证据截图/片段与时间戳
- 终审开启时,代理商通过后任务状态变为「待终审」
- 品牌方驳回或代理商驳回时,任务回到「脚本上传」阶段并重新进入 AI → 代理商 →(可选)品牌的复核流程
### 6.5 代理商管理
@@ -243,9 +255,11 @@
### 6.6 申诉与仲裁
**P1**
- 申诉令牌管理与工单流转
- 按任务独立的申诉次数管理(每任务初始1次)
- 达人申请增加申诉次数流程
- 代理商处理申诉次数请求(同意/拒绝)
- 人工仲裁流程与记录
- 审计日志完整可追溯
- 操作记录完整可追溯
### 6.7 版本差异与批量处理 [US-11, US-13]
@@ -269,34 +283,80 @@
---
### 6.9 数据看板与核心指标
**P0**
- 核心指标卡片(F-33):展示审核总量、初审通过率、硬性召回率、舆情拦截数、平均审核周期
**P1**
- 趋势图表(F-34):近 30 天审核量与通过率趋势
- 风险预警(F-35):竞品露出集中爆发、达人连续未通过、舆情异常上升
- 代理商绩效对比(F-36)与达人排行榜(F-37)
**验收要点**
- 指标口径与成功指标定义一致
- 指标数据可追溯到审计记录
---
## 7. 关键流程 (Key User Flows)
### 7.1 品牌方工作流
1. 制定并下达 Brief 投放要求
2. 配置品牌私有规则(禁用词、竞品列表、白名单)
3. 抽查最终视频审核报告
4. 处理严重争议与风险决策
5. 行使"强制通过权"处理误报
6. 导出审核证据链用于合规归档
1. 邀请代理商:通过代理商ID搜索并发送邀请,代理商同意后加入品牌方代理商列表
2. 创建项目并分配给代理商
3. 制定并下达 Brief 投放要求
4. 配置品牌私有规则(禁用词、竞品列表、白名单)
5. 抽查最终视频审核报告
6. 处理严重争议与风险决策
7. 行使"强制通过权"处理误报
8. 导出审核证据链用于合规归档
### 7.2 代理商工作流
1. 创建任务并上传 Brief
2. 系统解析 Brief 并生成规则集
3. 创建达人任务并发起脚本预审
4. 达人上传视频,系统自动审核
5. 审核员在审核台确认/驳回(基于红/黄/绿风险标记)
6. 进行人工仲裁(如有争议)
7. 导出报告与证据链
**代理商ID** 每个代理商拥有系统唯一的代理商ID(如 `AG123456`),用于品牌方精准邀请。代理商需先接受品牌方邀请才能接收项目分配。
### 7.3 达人工作流
1. 接收品牌方分配的项目(项目出现在Brief配置列表的"待配置"中)
2. 配置Brief:上传Brief文件,系统解析并生成规则集
3. 邀请达人:通过达人ID搜索并发送邀请,达人同意后加入代理商达人列表
4. 分配达人到项目
5. 达人在任务详情页提交脚本文档,脚本 AI 预审通过后进入等待代理商审核状态
6. 达人在任务详情页补充视频,系统自动审核
7. 审核员在审核台确认/驳回(基于红/黄/绿风险标记)
8. 若代理商/品牌方驳回:任务回到脚本上传阶段,重新进入脚本 AI → 代理商 →(可选)品牌流程
9. 进行人工仲裁(如有争议)
10. 导出报告与证据链
1. 上传脚本进行预审
2. 根据建议修改并提交视频
3. 查看 AI 审核进度(如"正在核对口播..."
4. 收到带时间戳的修改清单
5. 触发申诉或修改再提交
### 7.3 达人工作流(两阶段审核)
**达人ID** 每个达人拥有系统唯一的达人ID(如 `CR123456`),用于代理商精准邀请。达人需先接受代理商邀请才能接收任务。
**脚本阶段:**
1. 进入任务详情上传脚本文档进行预审
2. 等待脚本 AI 审核,查看审核进度
3. 若脚本 AI 审核不通过:查看修改意见,点击「重新提交脚本」重新上传
4. 脚本 AI 通过后,任务详情显示"等待代理商审核"状态
5. 若代理商/品牌方驳回脚本:点击「重新提交脚本」重新上传脚本
6. 脚本审核通过后,任务列表显示「查看详情」按钮
**视频阶段:**
7. **首次**点击「查看详情」进入结果页,查看脚本通过详情
8. 点击「下一步:上传视频」返回任务列表,此时按钮变为「上传视频」
9. 点击「上传视频」进入视频上传页,上传视频文件
10. 等待视频 AI 审核,查看审核进度(如"正在核对口播..."
11. 若视频 AI 审核不通过:查看修改清单,点击「重新上传视频」重新上传
12. 视频 AI 通过后,等待代理商/品牌方审核
13. 若代理商/品牌方驳回视频:点击「重新上传视频」重新上传视频
14. 视频审核通过后,点击「审核通过,可发布」完成任务
**历史归档:**
15. 当日 00:00 后,已通过任务自动归入历史记录
**申诉流程:**
- 对任意审核结论可触发申诉(消耗该任务的申诉次数)
- 每个任务初始申诉次数为 **1次**,不同任务独立计算
- 申诉次数不足时,可向代理商申请增加(无需理由)
- 代理商可在消息中心或达人管理页面处理申请(同意/拒绝/忽略)
---
@@ -306,7 +366,7 @@
| --- | --- | --- |
| 品牌方(含品牌方管理员) | 品牌内任务与规则 | 强制通过、规则管理、报告导出、私有规则配置、AI 服务商配置与管理 |
| 代理商 | 代理商管理范围 | 任务创建、审核确认/驳回、批量处理、人工仲裁、强制通过(按代理商授权,默认开启,可关闭) |
| 达人 | 自己的任务 | 上传脚本/视频、查看报告、申诉 |
| 达人 | 自己的任务 | 在任务详情上传脚本/视频、查看报告、申诉 |
---
@@ -319,13 +379,14 @@
- **规则集**:平台规则 + 品牌私有规则 + 白名单 + 规则版本记录
- **审核记录**:风险项、时间戳、证据片段、风险等级(红/黄/绿)
- **人工决策**:确认/驳回/强制通过 + 操作人 + 操作时间
- **申诉记录**:申诉原因、仲裁结论、令牌消耗
- **申诉记录**:申诉原因、仲裁结论、申诉次数变化(任务维度)
### 9.2 审计要求 [US-12]
- 全流程日志可追溯、不可篡改
- 导出报告包含规则版本、模型版本、证据截图/片段与时间戳
- 支持争议场景下完整审核证据链导出
- 操作日志采用 append-only + hash chain(前序哈希 + 当前内容)确保可追溯
---
@@ -394,13 +455,13 @@
- **ASR/OCR**:支持普通话及主流方言的语音识别,支持复杂背景字幕识别
- **计算机视觉**:Logo 检测、物体识别、场景分类
- **消息队列**:异步处理视频审核任务,支持优先级调度
- **AI 厂商动态配置**:品牌方管理员可在后台配置多个 AI 厂商(DeepSeek/OpenAI/OneAPI 等),运行时动态加载,支持多租户隔离和故障转移(详见 AIProviderConfig.md
- **AI 厂商动态配置**:品牌方管理员可在后台配置**单一 AI 提供商**(可为 OneAPI/OpenRouter 中转或直连厂商),运行时动态加载,支持多租户隔离;配置变更可随时切换(详见 AIProviderConfig.md
---
## 14. 里程碑与发布计划 (Milestones)
- **MVP (P0)**Brief 解析、规则加载、脚本预审、视频审核、审核台、语境理解降低误报、审核进度展示、基础黑白名单与竞品库、时长与频次校验、AI 服务商配置
- **MVP (P0)**Brief 解析、规则加载、脚本预审、视频审核、审核台、语境理解降低误报、审核进度展示、基础黑白名单与竞品库、时长与频次校验、AI 服务商配置、核心指标卡片
- **V1.1 (P1)**Brand Safety 提示、规则版本、证据链导出、强制通过权、高级豁免规则
- **V2 (P2)**:批量处理、版本差异报告
+6 -4
View File
@@ -2,7 +2,7 @@
| 文档类型 | **RD (Requirements Document)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.0 |
| **发布日期** | 2026-02-03 |
| **状态** | **修订 (Revised)** |
@@ -76,7 +76,7 @@
### 4.2 场景二:脚本预审 (Pre-production)
* **[US-03] [P0]** 作为 **达人**,我希望在拍摄前先提交文字脚本进行预审,让系统帮我检查是否遗漏了卖点或触犯了广告法,避免拍完重拍的巨大沉没成本。
* **[US-03] [P0]** 作为 **达人**,我希望在拍摄前先通过**脚本文档上传**PDF/Word/纯文本/Excel提交脚本进行预审,让系统帮我检查是否遗漏了卖点或触犯了广告法,避免拍完重拍的巨大沉没成本;脚本 AI 通过后任务进入**等待代理商审核**状态并在任务详情提示;若代理商或品牌方驳回,任务回到**脚本上传**阶段并重新进入 AI → 代理商 →(可选)品牌的复核流程(可循环)
* **[US-04] [P0]** 作为 **达人**,我希望审核系统能"读懂上下文",不要因为我在讲故事时说了"最开心的一天"就报"广告极限词违规",减少对创作的干扰。
### 4.3 场景三:视频智能审核 (Post-production)
@@ -131,10 +131,11 @@
3. **分区执法逻辑:** 智能区分“广告段”与“剧情段”,应用不同的审核尺度。
4. **舆情风控雷达:** 针对“油腻感”、“价值观风险”、“错别字”的专项检测模型。
5. **交互式审核台:** 支持时间戳打点、风险高亮、版本比对 (Diff) 的 Web 界面。
6. **信用与申诉体系:** 包含申诉令牌管理和人工仲裁流程。
6. **信用与申诉体系:** 包含按任务独立的申诉次数管理(每任务初始1次,可向代理商申请增加)和人工仲裁流程。
7. **规则库管理与版本控制:** 支持平台规则库更新、品牌私有规则与白名单配置。
8. **权限与多租户隔离:** 支持品牌/代理/达人不同角色的权限与数据隔离。
9. **审计日志与报告导出:** 支持导出可追溯的审核证据链。
10. **数据看板与核心指标:** 提供核心指标卡片与基础数据概览。
### ❌ Out of Scope (本期不做)
@@ -188,7 +189,7 @@
* **ASR/OCR:** 支持普通话及主流方言的语音识别,支持复杂背景字幕识别
* **计算机视觉:** Logo 检测、物体识别、场景分类
* **消息队列:** 异步处理视频审核任务,支持优先级调度
* **AI 厂商动态配置:** 支持在数据库中配置多个 AI 厂商(DeepSeek/OpenAI/OneAPI 等),运行时动态加载,支持多租户隔离和故障转移(详见 AIProviderConfig.md
* **AI 厂商动态配置:** 支持在数据库中配置**单一 AI 提供商**(可为 OneAPI/OpenRouter 中转或直连厂商),运行时动态加载,支持多租户隔离;配置变更可随时切换(详见 AIProviderConfig.md
---
@@ -213,6 +214,7 @@
* **性能:** ≤ 100MB 视频上传后,AI 预审报告产出时间不超过 5 分钟(含排队 ≤ 2 分钟)。
* **审计链路:** 每条结论包含规则版本、模型版本、证据截图/片段与时间戳。
* **F-45 时长与频次统计:** 时长统计误差 ≤ 1秒;频次统计准确率 ≥ 95%。
* **审计日志不可篡改:** 采用 append-only + hash chain(前序哈希 + 当前内容)校验可追溯。
---
+53 -34
View File
@@ -1,12 +1,23 @@
# UIDesign.md - 智能视频审核系统 UI 设计规范
> ⚠️ **重要说明 (2026-02-03)**
>
> 本文档为**早期设计参考文档**,设计风格为浅色系。
>
> **当前正式设计规范请参考:[UIDesignSpec.md](./UIDesignSpec.md)**
> - 设计稿文件:`pencil-new.pen`
> - 设计风格:Apple-style **暗色主题**
> - 包含最新的设计令牌、组件规范和页面清单
>
> 本文档仅保留用于历史参考和设计原则说明。开发时以 `UIDesignSpec.md` 为准。
| 文档类型 | **UI Design System (设计系统规范)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.1 |
| **发布日期** | 2026-02-03 |
| **设计风格** | Apple Human Interface Guidelines 浅色系 |
| **关联文档** | PRD.md, FeatureSummary.md, User_Role_Interfaces.md, DevelopmentPlan.md |
| **设计风格** | ~~Apple Human Interface Guidelines 浅色系~~ → 已更新为暗色主题,见 UIDesignSpec.md |
| **关联文档** | PRD.md, FeatureSummary.md, User_Role_Interfaces.md, DevelopmentPlan.md, **UIDesignSpec.md** |
---
@@ -16,22 +27,23 @@
| --- | --- | --- | --- |
| V1.0 | 2026-02-02 | Claude | 初稿:设计原则、色彩系统、组件库、三端界面规范 |
| V1.1 | 2026-02-02 | Claude | 新增第10章:AI 服务配置界面设计规范 |
| V1.2 | 2026-02-03 | Claude | 添加废弃声明,指向 UIDesignSpec.md(暗色主题) |
---
## 1. 设计原则 (Design Principles)
借鉴 Apple Human Interface GuidelinesSmartAudit 的设计遵循以下核心原则:
借鉴 Apple Human Interface Guidelines秒思智能审核平台 的设计遵循以下核心原则:
### 1.1 核心设计理念
| 原则 | 描述 | 在 SmartAudit 中的体现 |
| 原则 | 描述 | 在 秒思智能审核平台 中的体现 |
| --- | --- | --- |
| **Clarity (清晰)** | 文字清晰易读,图标精确传意,功能显而易见 | 审核结论用红/黄/绿 + 文字 + 图标三重表达 |
| **Deference (克制)** | UI 退居幕后,内容为王 | 审核台以视频和报告为核心,界面元素轻量化 |
| **Depth (层次)** | 通过视觉层次和流畅动效建立空间感 | 卡片悬浮阴影、模态弹窗、进度条层叠 |
### 1.2 SmartAudit 专属原则
### 1.2 秒思智能审核平台 专属原则
| 原则 | 说明 |
| --- | --- |
@@ -409,7 +421,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
├─────────────────────────────────────────────────────────────────┤
│ │
│ 🏠 📤 🔔 👤 │
│ 任务 上传 消息 我的 │
任务 消息 我的 │
│ │
│ ━━━━━━ ──── ──── ──── │
│ (选中态) (3) │
@@ -458,11 +470,11 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
└─────────────────────────────────────────────────────────────────┘
```
### 7.4 智能上传 (透明思考 UI)
### 7.4 任务详情页 - 上传 (透明思考 UI)
```
┌─────────────────────────────────────────────────────────────────┐
│ ◀ 返回 上传视频
│ ◀ 返回 任务详情 · 上传
├─────────────────────────────────────────────────────────────────┤
│ │
│ ╭─────────────╮ │
@@ -482,7 +494,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │
│ 👁️ 正在逐帧检测竞品 Logo... │
│ │
│ ✅ Brief 解析完成 00:05 │
│ ✅ 任务规则加载完成 00:05 │
│ ✅ ASR 语音转写完成 00:23 │
│ ◐ Logo 检测中... 进行中 │
│ ○ 语义分析 等待中 │
@@ -509,7 +521,14 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
- 底部提供"离开"选项,减少用户被困感
- 上传时显示防锁屏提示(Wake Lock API
### 7.5 审核结果页
**脚本上传功能要点(任务详情内):**
- 标题与任务信息:任务名、平台、截止时间、当前步骤(脚本)
- 文件上传(PDF/Word/纯文本/Excel
- 关键提示:脚本提交后进入 AI 预审,结果回到任务详情
- 提交按钮 + 校验:空内容禁止提交
- 草稿保存:支持本地或后端草稿保存
### 7.5 任务详情-审核结果区
```
┌─────────────────────────────────────────────────────────────────┐
@@ -572,12 +591,21 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
└─────────────────────────────────────────────────────────────────┘
```
**等待代理商审核态(脚本已通过)显示:**
- 任务信息头部:任务名、平台、截止时间、当前阶段(“等待代理商审核”)
- 审核流程进度条:当前阶段高亮,已完成阶段打勾
- 脚本提交信息:文件名/类型(PDF/Word/纯文本/Excel)、提交时间
- AI 脚本预审结果摘要:结论通过、简短说明、软性提示(Warn-only)以提示样式展示
- 等待提示:显示“已进入代理商审核,请耐心等待”
- 结果告知:提示后续结果将在消息中心提醒
**设计要点:**
- 顶部结果横幅用语义色背景,一目了然
- 视频进度条上标注问题时间点(红/黄点)
- 点击时间点可跳转视频对应位置
- 每条问题提供"跳转"和"申诉"两个操作
- 软性提示明确标注"不影响通过"
- 代理商/品牌方驳回:结果横幅显示“未通过”,主操作为“重新上传脚本”;任务回到脚本上传并触发脚本 AI 预审 → 代理商复审 →(可选)品牌终审(可循环)
### 7.6 申诉弹窗
@@ -612,8 +640,8 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │ │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ │ │
│ │ 💡 剩余申诉令牌:2 次 │ │
│ │ 申诉成功后令牌将自动返还 │ │
│ │ 💡 本任务剩余申诉次数:1 次 │ │
│ │ 每个任务独立计算,次数不足可向代理商申请增加 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ 提交申诉 │ │ │
@@ -641,7 +669,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │
│ ┌──────────┐ ┌────────────────────────────────────────────────────────┐ │
│ │ │ │ │ │
│ │ SmartAudit│ │ 页面内容区域 │ │
│ │ 秒思智能审核平台│ │ 页面内容区域 │ │
│ │ │ │ │ │
│ ├──────────┤ │ │ │
│ │ │ │ │ │
@@ -680,7 +708,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 张三 │
秒思智能审核平台 🔔 (3) 👤 张三 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 工作台 │ 工作台 │
@@ -727,7 +755,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 张三 │
秒思智能审核平台 🔔 (3) 👤 张三 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 工作台 │ Brief 配置中心 │
@@ -769,7 +797,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 张三 │
秒思智能审核平台 🔔 (3) 👤 张三 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 工作台 │ 审核决策台 ⬅️ ➡️ │
@@ -789,16 +817,9 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
│ │ │ │ 置信度 92% │ │
│ ⚙️ 设置 │ │ ──────────────────── │ [展开详情] [查看截图] │ │
│ │ │ 🔴 🔴 🟡 │ │ │
│ │ │ ▼ ▼ ▼ │ Brief 完成度 │ │
│ │ │ ▼ ▼ ▼ │ 舆情雷达 │ │
│ │ │ ░░░░░░░░░░░░░░░░░░░░ │ ───────────────────────────── │ │
│ │ │ 00:00 02:30 │ │ │
│ │ │ │ ✅ 卖点1:美白 │ │
│ │ │ ┌──────────────────┐ │ ✅ 卖点2:补水 │ │
│ │ │ │ │ │ ❌ 卖点3:24小时持妆 (未提及) │ │
│ │ │ │ Brief 参考图 │ │ │ │
│ │ │ │ (画中画悬浮) │ │ 舆情雷达 │ │
│ │ │ │ │ │ ───────────────────────────── │ │
│ │ │ └──────────────────┘ │ │ │
│ │ │ │ 🟡 01:28 油腻风险 (仅提示) │ │
│ │ │ │ 达人表情过于夸张 │ │
│ │ │ │ │ │
@@ -818,11 +839,10 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
**设计要点:**
- 左侧 60%:视频播放器 + 智能进度条(红/黄/绿点)
- 右侧 40%:AI 检查单,分为"硬性合规"、"Brief 完成度"、"舆情雷达"
- 右侧 40%:AI 检查单,分为"硬性合规"、"舆情雷达"
- 底部固定操作栏,三个决策按钮
- 品牌方**按代理商**关闭授权时,“强制通过”按钮改为“申请强制通过”,点击弹出原因并提交审批
- 强制通过弹窗包含“保存为特例”勾选项(默认不勾选),勾选后生成豁免条款并等待品牌方确认
- Brief 参考图可悬浮在视频角落对比
### 8.6 版本比对视窗
@@ -892,7 +912,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 王总 │
秒思智能审核平台 🔔 (3) 👤 王总 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 数据 │ 数据看板 本月 ▼ 导出报告 ▼ │
@@ -936,7 +956,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 王总 │
秒思智能审核平台 🔔 (3) 👤 王总 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 数据 │ 规则配置 │
@@ -998,7 +1018,7 @@ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
```
┌────────────────────────────────────────────────────────────────────────────┐
SmartAudit 🔔 (3) 👤 王总 │
秒思智能审核平台 🔔 (3) 👤 王总 │
├──────────┬─────────────────────────────────────────────────────────────────┤
│ │ │
│ 📊 数据 │ AI 服务配置 │
@@ -1411,8 +1431,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
| 角色 | 页面名称 | 优先级 | 设计状态 |
| --- | --- | --- | --- |
| **达人** | 任务列表 | P0 | 待设计 |
| | 智能上传 (透明思考 UI) | P0 | 待设计 |
| | 审核结果 | P0 | 待设计 |
| | 任务详情上传 (透明思考 UI) | P0 | 待设计 |
| | 任务详情-审核结果 | P0 | 待设计 |
| | 申诉弹窗 | P1 | 待设计 |
| | 消息中心 | P1 | 待设计 |
| | 历史记录 | P2 | 待设计 |
@@ -1425,9 +1445,8 @@ Desktop (> 1024px) Tablet (768px - 1024px)
| **品牌方** | 数据看板 | P0 | 待设计 |
| | 规则配置 | P0 | 待设计 |
| | AI 服务配置 | P0 | 已设计 |
| | 审计日志 | P1 | 待设计 |
| | 代理商管理 | P1 | 待设计 |
| | 舆情预警 | P2 | 设计 |
| | 终审台 | P0 | 设计 |
---
+136 -44
View File
@@ -2,9 +2,9 @@
| 文档类型 | **UI Design Specification** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **项目名称** | 秒思智能审核平台 (AI 营销内容合规审核平台) |
| **版本号** | V1.0 |
| **发布日期** | 2026-02-03 |
| **发布日期** | 2026-02-05 |
| **设计稿文件** | `pencil-new.pen` |
| **设计风格** | Apple-style 暗色主题,商业级/高端质感 |
@@ -81,9 +81,8 @@
| 个人中心 | `user` | 底部导航、我的 |
| 达人管理 | `users` | 侧边栏导航 |
| 数据看板/报表 | `chart-column` | 底部导航、侧边栏 |
| 舆情预警 | `triangle-alert` | 侧边栏导航 |
| 代理商管理 | `building-2` | 侧边栏导航 |
| 审计日志 | `scroll-text` | 侧边栏导航 |
| 终审台 | `shield-check` | 侧边栏导航 |
| 系统设置 | `settings` | 侧边栏导航 |
| Brief管理 | `file-text` | 侧边栏导航 |
| 版本比对 | `git-compare` | 侧边栏导航 |
@@ -244,53 +243,136 @@
### 4.1 达人端 (Creator)
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
| --- | --- | --- | --- |
| 任务列表 | Mobile | P0 | PjBJD |
| 智能上传 | Mobile | P0 | ZelCS |
| 审核结果 | Mobile | P0 | Vn3VU |
| AI审核中 | Mobile | P0 | lzdm4 |
| 消息中心 | Mobile | P1 | pF15t |
| 历史记录 | Mobile | P2 | ZKEFl |
| 个人中心 | Mobile | P2 | zCdM1 |
| 任务列表 | Desktop | P0 | HD3eK |
| 智能上传 | Desktop | P0 | N79bL |
| 审核结果 | Desktop | P0 | 3niUa |
| AI审核中 | Desktop | P0 | bxAKT |
| 消息中心 | Desktop | P1 | 8XKLP |
> **两阶段审核说明:** 每个任务包含「脚本阶段」和「视频阶段」两轮审核
#### 4.1.1 Mobile 端页面
| 页面名称 | 阶段 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- | --- |
| 任务列表 | 通用 | P0 | PjBJD | 含历史任务入口 |
| 脚本上传区 | 脚本阶段 | P0 | ZelCS | 上传脚本文档 |
| 脚本AI审核中 | 脚本阶段 | P0 | lzdm4 | 透明思考UI |
| 脚本AI审核通过 | 脚本阶段 | P0 | Vn3VU | 结果页,含「下一步:上传视频」 |
| 脚本AI审核不通过 | 脚本阶段 | P0 | cjcZZ | 结果页,含「重新提交脚本」 |
| 脚本代理商审核通过 | 脚本阶段 | P0 | IyLsO | 结果页 |
| 脚本代理商审核不通过 | 脚本阶段 | P0 | zU3Op | 结果页,含「重新提交脚本」 |
| 脚本品牌方审核通过 | 脚本阶段 | P0 | f6T3z | 结果页 |
| 脚本品牌方审核不通过 | 脚本阶段 | P0 | NeF4L | 结果页,含「重新提交脚本」 |
| 视频上传区 | 视频阶段 | P0 | (待补充) | 上传视频文件 |
| 视频AI审核中 | 视频阶段 | P0 | (待补充) | 透明思考UI |
| 视频AI审核通过 | 视频阶段 | P0 | (待补充) | 结果页 |
| 视频AI审核不通过 | 视频阶段 | P0 | 6EX4Z | 结果页,含「重新上传视频」 |
| 视频代理商审核通过 | 视频阶段 | P0 | (待补充) | 结果页 |
| 视频代理商审核不通过 | 视频阶段 | P0 | (待补充) | 结果页,含「重新上传视频」 |
| 视频品牌方审核通过 | 视频阶段 | P0 | (待补充) | 结果页,含「审核通过,可发布」 |
| 视频品牌方审核不通过 | 视频阶段 | P0 | (待补充) | 结果页,含「重新上传视频」 |
| 消息中心 | 通用 | P1 | pF15t | 两阶段审核通知 |
| 历史记录 | 通用 | P2 | ZKEFl | 当日00:00后自动归档 |
| 个人中心 | 通用 | P2 | zCdM1 | - |
#### 4.1.2 Desktop 端页面
| 页面名称 | 阶段 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- | --- |
| 任务列表 | 通用 | P0 | HD3eK | 含历史任务入口 |
| 脚本上传区 | 脚本阶段 | P0 | N79bL | 上传脚本文档 |
| 脚本AI审核中 | 脚本阶段 | P0 | bxAKT | 透明思考UI |
| 脚本审核结果 | 脚本阶段 | P0 | 3niUa | 通用结果页 |
| 消息中心 | 通用 | P1 | 8XKLP | 两阶段审核通知 |
### 4.2 代理商端 (Agency)
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
> **侧边栏导航顺序:** 工作台 → 审核台 → Brief配置 → 达人管理 → 数据报表 → 消息中心
#### 4.2.1 Desktop 端页面
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- |
| 工作台 | Desktop | P0 | RX8V9 |
| 审核决策台 | Desktop | P0 | 2u8Bq |
| Brief配置中心 | Desktop | P0 | djd2K |
| 达人管理 | Desktop | P1 | 5cFMX |
| 数据报表 | Desktop | P1 | An8gw |
| 版本比对 | Desktop | P2 | NDmYh |
| 工作台 | Mobile | P0 | VuH3F |
| 快捷审核 | Mobile | P0 | lrHaj |
| 任务列表 | Mobile | P1 | c6SPa |
| 消息中心 | Mobile | P1 | 9Us9g |
| 个人中心 | Mobile | P2 | 8OCZ3 |
| 工作台 | P0 | RX8V9 | 待办统计+快捷入口,默认首页 |
| 项目详情 | P0 | C7wfV | 项目数据和达人列表 |
| 审核台(列表页) | P0 | zjiCT | 脚本/视频待审列表 |
| 脚本审核决策台 | P0 | f8HX9 | 简单模式(文件图标+预览按钮) |
| 脚本审核(预览模式) | P0 | Wct5R | 展开脚本内容+AI分析 |
| 视频审核决策台 | P0 | 2u8Bq | 视频播放+问题标记+决策 |
| Brief配置中心(列表页) | P0 | Nicby | 待配置/已配置列表 |
| Brief配置详情(待配置) | P0 | jRsW5 | 上传Brief+配置规则 |
| Brief配置详情(已配置) | P0 | b06fU | 查看/编辑配置 |
| 达人管理 | P1 | 5cFMX | 达人列表+邀请 |
| 邀请达人弹窗 | P1 | ADN10 | 邀请达人模态框 |
| 数据报表 | P1 | An8gw | 项目数据统计 |
| 消息中心 | P1 | PfMR0 | 通知列表 |
#### 4.2.2 Mobile 端页面
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- |
| 工作台 | P0 | VuH3F | 紧急待办概览 |
| 快捷审核 | P0 | lrHaj | 外出场景审核 |
| 任务列表 | P1 | c6SPa | 任务筛选 |
| 消息中心 | P1 | 9Us9g | 通知列表 |
| 个人中心 | P2 | 8OCZ3 | 个人设置 |
#### 4.2.3 页面跳转关系
```
侧边栏导航
├── 工作台 ───────────► 我的项目 [查看] → 项目详情
│ 紧急待办 [审核] → 审核决策台
├── 审核台 ───────────► 审核台(列表页)
│ ├── 脚本任务 ────► 脚本审核决策台 ─► 预览脚本按钮 → 预览模式
│ └── 视频任务 ────► 视频审核决策台
├── Brief配置 ────────► Brief配置中心(列表页)
│ ├── 待配置项目 ──► Brief配置详情(待配置) ─► 保存 → 已配置列表
│ └── 已配置项目 ──► Brief配置详情(已配置)
├── 达人管理 ─────────► 邀请按钮 → 邀请达人弹窗
├── 数据报表 ─────────► 数据报表页
└── 消息中心 ─────────► 消息中心页
```
### 4.3 品牌方端 (Brand)
| 页面名称 | 设备 | 优先级 | 设计稿节点ID |
> **侧边栏导航顺序:** 项目看板 → 创建项目 → 终审台 → 代理商管理 → 规则配置 → AI配置 → 系统设置
#### 4.3.1 Desktop 端页面
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- |
| 数据看板 | Desktop | P0 | xUM9m |
| AI服务配置 | Desktop | P0 | 4ppiJ |
| 规则配置 | Desktop | P0 | nhHSF |
| 审计日志 | Desktop | P1 | jELTK |
| 代理商管理 | Desktop | P1 | 2jnnO |
| 舆情预警 | Desktop | P2 | NjCe7 |
| 系统设置 | Desktop | P2 | 4nVj4 |
| 数据看板 | Mobile | P0 | lpVdV |
| 舆情预警 | Mobile | P1 | wWAel |
| 审批中心 | Mobile | P1 | OueOe |
| 消息中心 | Mobile | P2 | 1w9xC |
| 我的 | Mobile | P2 | OJBbT |
| 项目看板 | P0 | xUM9m | 项目列表与数据概览,默认首页 |
| 项目详情数据看板 | P1 | D1O6f | 单项目数据分析 |
| 创建项目 | P0 | fP5rY | 新建项目表单 |
| 终审台(列表页) | P0 | afJEU | 脚本/视频待终审列表 |
| 脚本终审决策台 | P0 | Sw2hw | 简单模式(文件图标+预览按钮) |
| 脚本终审(预览模式) | P0 | cp5CE | 展开脚本内容审核 |
| 视频终审决策台 | P0 | aePi5 | 视频播放+问题标记+决策 |
| 代理商管理 | P1 | 2jnnO | 代理商列表与邀请 |
| 邀请代理商弹窗 | P1 | GyUlM | 邀请代理商模态框 |
| 规则配置 | P0 | nhHSF | 黑名单/白名单管理 |
| AI服务配置 | P0 | 4ppiJ | AI模型与参数配置 |
| 系统设置 | P2 | 4nVj4 | 通用设置、安全、数据导出 |
#### 4.3.2 Mobile 端页面
| 页面名称 | 优先级 | 设计稿节点ID | 备注 |
| --- | --- | --- | --- |
| 数据看板 | P0 | lpVdV | 关键指标概览 |
| 审批中心 | P1 | OueOe | 终审处理 |
| 消息中心 | P2 | 1w9xC | 通知列表 |
| 我的 | P2 | OJBbT | 个人设置 |
#### 4.3.3 页面跳转关系
```
侧边栏导航
├── 项目看板 ─────────► 点击项目卡片 → 项目详情数据看板
├── 创建项目 ─────────► 填写表单 → 保存 → 项目看板
├── 终审台 ───────────► 终审台(列表页)
│ ├── 脚本任务 ────► 脚本终审决策台 ─► 预览脚本按钮 → 预览模式
│ └── 视频任务 ────► 视频终审决策台
├── 代理商管理 ───────► 邀请按钮 → 邀请代理商弹窗
├── 规则配置 ─────────► 黑名单/白名单管理
├── AI配置 ───────────► AI服务配置页
└── 系统设置 ─────────► 系统设置页
```
---
@@ -388,4 +470,14 @@ module.exports = {
---
**文档维护者**: Claude
**最后更新**: 2026-02-03
**最后更新**: 2026-02-06
---
## 版本历史
| 版本 | 日期 | 作者 | 变更说明 |
| --- | --- | --- | --- |
| V1.0 | 2026-02-03 | Claude | 初稿:设计令牌、组件规范、页面清单 |
| V1.1 | 2026-02-05 | Claude | **明确两阶段审核页面**:细化达人端页面清单,按脚本阶段/视频阶段分类;新增脚本品牌方不通过(NeF4L)、视频AI不通过(6EX4Z)页面 |
| V1.2 | 2026-02-06 | Claude | **完善品牌方端和代理商端页面清单**:更新侧边栏导航顺序;新增规则配置、脚本终审(预览模式)等页面;补充页面跳转关系图 |
+679 -193
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>SmartAudit · AI 服务配置(说明版)</title>
<title>秒思智能审核平台 · AI 服务配置(说明版)</title>
<style>
:root {
--bg: #0b0c0f;
+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()

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