feat: add AirShelf core implementation

This commit is contained in:
zyc 2026-06-03 13:16:32 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions

11
.gitignore vendored
View File

@ -1,7 +1,18 @@
node_modules
.next
out
dist
.env*.local
.env
.venv
__pycache__/
*.pyc
db.sqlite3
.playwright-cli
account.md
core/frontend/output/
core/qa/*.png
core/qa/visual-parity/output/
.DS_Store
*.tsbuildinfo
next-env.d.ts.bak

View File

@ -73,6 +73,13 @@
- **设计稿优先** · 写代码前必须先读 [电商AI平台/_design_src/](电商AI平台/_design_src/) 设计稿(如果有)
- **`.pen` 文件加密** · 只能用 pencil MCP 工具,不能 Read/Grep
## 本机连接备忘
- 火山 MySQL 公网域名 `mysql-8351f937d637-public.rds.volces.com` 在本机可能被 TUN / 代理解析到 `198.18.x.x` fake-ip,导致 MySQL 握手阶段断开。
- 本机开发连接测试 MySQL 时,优先使用真实公网 IP `14.103.27.192`,并加 `--bind-address=192.168.124.86`
- 部署到火山内网 / K8s 时,优先使用私网地址 `mysql8351f937d637.rds.ivolces.com`
- 账号、密码、ARK/TOS/Redis 等敏感信息记录在 [account.md](account.md),不要复制到本文件。
---
## 用户偏好

702
core/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,702 @@
# AirShelf 技术架构方案
> 版本v1.0
> 日期2026-05-29
> 定位:从原型走向真实可运营产品的顶层技术架构决策文档
> 适用范围Django 后端、前端产品化、火山 ARK AI 接入、额度账本、运营后台、60s 多段视频生产
---
## 1. 架构结论
AirShelf 不应该先横向补齐所有页面,而应该先打穿一条真实生产闭环:
商品创建 -> 项目创建 -> AI 脚本 -> 基础资产 -> 可选故事板 -> 4 段视频生成 -> FFmpeg 拼接导出 -> 额度确认扣费 -> 资产入库 -> 运营后台可观测
第一阶段采用“模块化单体 + 异步任务”的架构:
- 后端Django + Django REST Framework
- 数据库MySQL
- 缓存/队列/锁Redis
- 异步任务Celery Worker + Celery Beat
- 文件存储:火山 TOS
- AI 模型:火山 ARK统一 Provider 抽象
- 运营后台:先用 Django Admin + 少量自定义后台页
- 前端React + Vite 单页应用。以 `v1/*.html` 为核心视觉规格,`电商AI平台/*.html` 作为未迁移页面和原版能力补充,重建为真实前端应用
- 路由:正式业务入口使用 React History URL例如 `/products``/projects/new``/pipeline/:id``/exact/*.html` 只作为像素级设计稿镜像和视觉回归基线,不作为产品业务路由
暂不采用微服务。当前阶段最大风险不是服务边界不够细而是任务状态、扣费账本、AI 失败恢复、资产流转没有被一套一致的数据模型兜住。模块化单体更容易保证事务一致性,也更适合快速把 PRD 全量能力落地。
---
## 2. 核心原则
### 2.1 账本优先
额度系统不能后补。所有 AI 任务、导出任务、重跑任务都必须先经过额度预检,并由账本记录冻结、确认扣费、失败释放、人工调整。
关键原则:
- 失败不扣费
- 用户确认采用后扣费
- 预估消耗需要额度预检
- 扣费必须幂等
- 所有账务变更必须有流水
### 2.2 任务异步化
火山生图、生视频、视频拼接都不能放在同步 HTTP 请求里执行。API 只负责创建任务、返回 task_idWorker 负责执行、轮询、重试、写状态。
### 2.3 资产对象化
图片、视频、成片不直接存数据库。数据库只存 TOS object key、元数据、归属、状态、引用关系。所有中间产物都应成为可追踪 Asset。
### 2.4 状态机先行
项目、阶段、AI 任务、视频片段、导出任务都必须有清晰状态机。不要只靠布尔字段拼状态,否则 60s 多段生产会很快失控。
### 2.5 单项目多段并发
60s 视频按 4 段 x 15s 生产。每段是独立 VideoSegment 和独立 AIJob可并发、可单段失败、可单段重跑、可回选历史版本。
---
## 3. 系统拓扑
```text
Browser
|
| HTTPS
v
Frontend Web
|
| REST / SSE or WebSocket
v
Django API
|
| ORM
v
MySQL
Django API
|
| enqueue task
v
Redis broker
|
v
Celery Workers
| | |
| | +--> FFmpeg export
| +----------> TOS upload/download
+------------------> Volcano ARK
Celery Workers
|
| status / ledger / asset metadata
v
MySQL
Django Admin / Ops
|
v
MySQL + task logs + billing ledger
```
部署形态:
- `airshelf-web`:前端静态资源或 SSR 前端服务
- `airshelf-api`Django API
- `airshelf-worker-default`:通用任务
- `airshelf-worker-ai`AI 文本/图片/视频任务
- `airshelf-worker-media`FFmpeg 拼接、转码、缩略图
- `airshelf-beat`定时任务、超时扫描、TOS 临时文件清理
---
## 4. 应用模块划分
建议 Django apps
```text
apps/
accounts/ 用户、登录、JWT、团队成员
teams/ 团队、角色、邀请、权限
products/ 商品库、卖点、商品图
projects/ 项目、阶段、脚本、分镜
assets/ 资产库、TOS 文件、引用关系
ai/ 火山 Provider、AIJob、模型配置
pipeline/ 5 阶段编排、视频片段、故事板
billing/ 额度账户、冻结、扣费、流水、套餐
media/ FFmpeg 拼接、字幕、BGM、导出
ops/ 运营后台扩展、任务监控、财务对账
common/ 审计字段、软删除、幂等、锁、工具
```
模块边界:
- `ai` 不直接扣费,只上报任务结果和预估成本。
- `billing` 不调用火山,只处理额度、冻结、确认扣费和流水。
- `assets` 不理解业务阶段,只管理文件、资产类型、引用和权限。
- `pipeline` 负责把 PRD 的 5 个 Stage 串起来。
- `ops` 只读为主,人工调整必须写审计日志。
---
## 5. 关键数据模型
### 5.1 账户与团队
- `User`
- `Team`
- `TeamMember`
- `Invitation`
- `Role`
V1 决策:
- 一个用户默认属于一个团队。
- 注册自动创建团队,注册者为超管。
- 预留多团队字段,但 V1 不开放切换多团队。
### 5.2 商品与项目
- `Product`
- `ProductImage`
- `ProductSellingPoint`
- `Project`
- `ProjectStageState`
- `Script`
- `ScriptShot`
Project 关键字段:
- `team_id`
- `product_id`
- `creator_id`
- `target_duration_seconds`30 / 45 / 60
- `segment_count`2 / 3 / 4
- `current_stage`
- `status`
### 5.3 资产
- `Asset`
- `AssetVersion`
- `AssetReference`
资产类型:
- product_image
- product_triptych
- character_portrait
- character_triptych
- scene_image
- storyboard
- video_clip
- final_video
- bgm
- subtitle
关键字段:
- `team_id`
- `project_id`
- `owner_id`
- `tos_key`
- `mime_type`
- `duration_seconds`
- `width`
- `height`
- `source`
- `status`
- `is_shared`
### 5.4 AI 任务
- `AIJob`
- `AIJobAttempt`
- `ModelConfig`
AIJob 关键字段:
- `job_type`text / image / video
- `provider`volcengine
- `model_name`
- `request_payload`
- `response_payload`
- `external_task_id`
- `status`
- `progress`
- `error_code`
- `error_message`
- `estimated_cost`
- `actual_cost`
- `idempotency_key`
任务状态:
```text
created -> quota_checked -> queued -> submitted -> polling -> succeeded
-> failed
-> timeout
-> cancelled
```
### 5.5 视频片段与导出
- `VideoSegment`
- `VideoSegmentVersion`
- `ExportJob`
- `TimelineItem`
- `SubtitleCue`
VideoSegment
- `project_id`
- `segment_index`
- `start_second`
- `end_second`
- `prompt`
- `use_storyboard`
- `adopted_version_id`
- `status`
60s 项目生成 4 个 VideoSegment
- 0-15s
- 15-30s
- 30-45s
- 45-60s
### 5.6 额度与财务
- `Wallet`
- `QuotaPolicy`
- `QuotaUsage`
- `BillingTransaction`
- `BillingHold`
- `PricingRule`
- `RechargeOrder`
四层额度:
- 用户日额度
- 用户月额度
- 团队月额度
- 团队总额度池
账务动作:
- estimate
- hold
- release
- charge
- refund
- manual_adjust
所有扣费以 `BillingTransaction` 为准,不从任务表反推财务结果。
---
## 6. Redis 设计
Redis DB index
- DB 0Django cache
- DB 1Celery broker
- DB 2Celery result backend
- DB 3分布式锁、幂等锁、防重复扣费锁
- DB 4限流、验证码计数、短期风控
- DB 5任务进度 pubsub / WebSocket 预留
锁设计:
- `lock:billing:confirm:{job_id}`
- `lock:project:generate:{project_id}`
- `lock:segment:generate:{segment_id}`
- `lock:export:{project_id}`
锁必须有 TTL且所有关键写入仍要依赖数据库唯一约束保证最终幂等。
---
## 7. 火山 ARK Provider 设计
所有模型通过统一 Provider 调用:
```text
AIProvider
generate_text()
generate_image()
create_video_task()
get_video_task()
```
当前模型决策来自 `account.md`,代码只读取环境变量:
- 文本主模型DeepSeek-V3-2
- 文本备用模型Doubao Seed 2.0 Pro / Lite
- 图片模型Seedream 5.0 Lite / 5.0 / 4.5
- 视频模型Seedance 2.0 / 2.0 Fast / 1.5 Pro
接口策略:
- 文本OpenAI-compatible chat
- 图片:同步或短异步,统一落成 AIJob
- 视频:异步任务,提交后轮询
- 所有外部响应原文进入 `response_payload`,便于排障
模型配置不硬编码在业务流程中。业务流程只声明用途:
- script_generation
- asset_prompt_generation
- product_image_optimize
- storyboard_generation
- video_segment_generation
由 ModelConfig 决定具体模型。
---
## 8. 60s 多段生产流程
### 8.1 Stage 1 脚本
输入:
- 商品信息
- 卖点
- 目标时长
- 用户指令
输出:
- Script
- ScriptShot
- 自动切段结果
60s 输出要求:
- `segment_count = 4`
- 每段约 15s
- 每个镜头必须归属 segment_index
### 8.2 Stage 2 基础资产
生成:
- 商品三视图
- 人物立绘
- 人物三视图
- 场景图
资产候选规则:
- 创意选择型一次 4 张
- 结构转换型一次 1 张
- 采用后才进入当前项目引用
### 8.3 Stage 3 故事板
故事板是可选项,不是硬前置。
如果生成:
- 每段 1 张故事板图
- 60s 项目最多 4 张
- 每张可独立重跑
### 8.4 Stage 4 视频片段
每个 VideoSegment 独立生成:
- 输入:脚本分段、基础资产、可选故事板、视频提示词
- 输出VideoSegmentVersion
- 用户采用某一版后,才进入可拼接素材
并发策略:
- 单项目最多 4 段并发
- 全局并发由 Worker 数和 Redis 队列控制
- 外部配额不足时降级到每项目 2 段并发
### 8.5 Stage 5 拼接导出
输入:
- 已采用的视频片段
- 时间线配置
- 字幕
- BGM
- 转场
输出:
- final_video Asset
- ExportJob
第一版导出能力:
- 单主轨
- 排序
- 裁剪
- 字幕烧录
- BGM 混音
- 9:16
- 1080P MP4
---
## 9. API 设计原则
API 应按资源和动作拆分,不把复杂动作塞进一个“大生成接口”。
示例:
```text
POST /api/products/
GET /api/products/
POST /api/projects/
GET /api/projects/{id}/
POST /api/projects/{id}/script/generate/
POST /api/projects/{id}/script/confirm/
POST /api/projects/{id}/assets/generate/
POST /api/assets/{id}/adopt/
POST /api/projects/{id}/storyboards/generate/
POST /api/storyboards/{id}/adopt/
POST /api/video-segments/{id}/generate/
POST /api/video-segment-versions/{id}/adopt/
POST /api/projects/{id}/exports/
GET /api/exports/{id}/
GET /api/ai-jobs/{id}/
POST /api/billing/estimate/
GET /api/billing/transactions/
```
前端轮询策略:
- AIJob 详情接口提供统一进度。
- Stage 页面不直接轮询火山。
- 后续可用 SSE/WebSocket 替代轮询。
---
## 10. 运营后台
第一版用 Django Admin 承担运营后台,不另起复杂后台前端。
必须有:
- 用户管理
- 团队管理
- 额度账户
- 消费流水
- AIJob 任务监控
- 视频片段与导出任务
- 模型配置
- PricingRule
- 人工补偿/退款/额度调整
人工操作要求:
- 必须写审计日志
- 财务调整必须写 BillingTransaction
- 禁止直接改余额字段绕过账本
---
## 11. 部署与环境
环境:
- local
- test
- production
敏感配置:
- 本地测试凭据记录在 `account.md`
- 代码与架构文档不保存真实密钥
- K8s 使用 Secret 注入环境变量
K8s 工作负载:
```text
Deployment airshelf-web
Deployment airshelf-api
Deployment airshelf-worker-default
Deployment airshelf-worker-ai
Deployment airshelf-worker-media
Deployment airshelf-beat
Service airshelf-web
Service airshelf-api
Ingress airshelf
Secret airshelf-env
ConfigMap airshelf-config
```
CI/CD 需要从当前纯静态部署升级为多镜像构建:
- web image
- api image
- worker image 可复用 api image启动命令不同
---
## 12. 可观测性
日志:
- API request log
- AI provider request/response summary
- Celery task log
- billing ledger log
- export job log
指标:
- AI 任务成功率
- AI 平均耗时
- 视频段失败率
- 导出失败率
- 队列长度
- Worker 并发
- TOS 上传失败率
- 额度冻结未释放数量
告警:
- AI 任务连续失败
- 队列堆积
- 导出任务超时
- Billing hold 超时未释放
- Redis / MySQL 不可用
---
## 13. 安全与权限
权限模型:
- 超管:团队所有权限、充值、额度划拨、财务查看
- 团管:成员管理、成员额度分配、团队资产管理
- 成员:创建项目、使用额度、管理自己的项目
安全要求:
- 所有 API 必须按 team_id 做数据隔离
- 资产下载使用签名 URL
- 上传文件做类型、大小、时长校验
- 后台人工操作写审计
- ARK/TOS/Redis/MySQL 密钥只走环境变量
- JWT refresh token 需要轮换和黑名单
---
## 14. 关键风险与架构应对
| 风险 | 应对 |
| --- | --- |
| PRD 60s 与页面流程 15s 口径冲突 | 以 60s 多段为工程目标,页面文案后续统一 |
| AI 任务失败或超时 | AIJob 状态机 + Attempt + 重试 + 单段重跑 |
| 重复扣费 | BillingHold + 幂等 key + Redis lock + DB 唯一约束 |
| 外部模型并发不足 | 队列限流,单项目并发可降级 |
| TOS 文件失控增长 | tmp 前缀清理任务,资产软删除,引用检查 |
| 视频导出耗时长 | media worker 独立队列,任务进度入库 |
| 运营后台需求膨胀 | V1 先 Django Admin后续再独立后台 |
---
## 15. 开发路线
### Phase 0工程初始化
- 创建 Django 项目
- 配置 MySQL / Redis / Celery / TOS
- Dockerfile 与 K8s 基础部署
- 健康检查与环境变量管理
验收:
- API 可启动
- Worker 可启动
- 能连接 MySQL / Redis
- 能上传测试文件到 TOS
### Phase 1业务地基
- 用户、团队、角色
- 商品库
- 项目
- 资产模型
- AIJob
- Billing 账本
验收:
- 注册自动建团队
- 商品 CRUD
- 项目创建
- 额度预检、冻结、释放、确认扣费可跑通
### Phase 2AI 纵向闭环
- 脚本生成
- 基础资产生成
- 故事板生成
- 4 段视频生成
- 结果入 TOS 和资产库
验收:
- 一个 60s 项目可生成 4 个视频片段
- 单段失败可重跑
- 用户采用后扣费
### Phase 3导出与前端联调
- FFmpeg 拼接
- 字幕
- BGM
- 1080P MP4 导出
- 前端接真实 API
验收:
- 4 段视频可导出成 60s 成片
- 成片入库
- 可下载、可预览
### Phase 4运营后台与上线硬化
- Django Admin 增强
- 任务监控
- 财务对账
- 模型配置
- 日志告警
- 并发压测
验收:
- 运营能查任务、查用户、查流水、人工调整额度
- 失败任务可定位
- 队列堆积可观测
---
## 16. 最终判断
AirShelf 的架构核心不是“页面数量”而是“AI 生产系统 + 账本系统 + 资产系统”的一致性。
正确的第一目标是:
> 用 Django + Celery + TOS + 火山 ARK 打穿真实 60s 多段视频生产闭环,并保证失败恢复和扣费一致性。
页面可以逐步接入,运营后台可以先用 Django Admin但账本、任务状态机、资产引用和 AI Provider 必须从第一天按真实产品设计。

View File

@ -0,0 +1,56 @@
# AirShelf 原型还原核对记录
更新时间2026-05-29
## 核对结论
当前 React 前端已按 `v1``电商AI平台` 原型补回主页面、隐藏子页面、按钮跳转和弹窗状态。真实生视频测试不在本轮执行,必须等页面还原、非视频接口、额度和运营后台能力都完成后再最后测试。
## 页面对齐矩阵
| 原型页面 | React 路由 | 状态 |
| --- | --- | --- |
| `v1/index.html` | `#dashboard` | 已接入 |
| `v1/products.html` | `#products` | 已接入 |
| `电商AI平台/product-create-upload.html` | `#productCreateUpload` | 已补齐 |
| `电商AI平台/product-detail.html` | `#productDetail` | 已补齐 |
| `v1/projects.html` | `#projects` | 已接入列表/网格/删除确认 |
| `v1/projects-new.html` | `#projectWizard` | 已补齐 |
| `v1/pipeline.html` | `#pipeline` | 已接入 5 Stage 自由切换 |
| `v1/library.html` | `#library` | 已接入上传抽屉 |
| `电商AI平台/account.html` | `#account` | 已补齐充值弹窗和 4 类消费 Tab |
| `电商AI平台/team.html` | `#team` | 已补齐成员表、权限、额度、创建/编辑/重置/动态弹窗 |
| `电商AI平台/messages.html` | `#messages` | 已补齐收件箱、详情、处理记录、动作跳转 |
| `电商AI平台/settings.html` | `#settings`, `#settingsNotify` | 已补齐多分区与通知入口 |
| `电商AI平台/asset-factory.html` | `#assetFactory` | 已补齐三类工具入口和任务中心 |
| `电商AI平台/image-optimize.html` | `#imageOptimize` | 已补齐工作台 |
| `电商AI平台/model-photo.html` | `#modelPhoto` | 已补齐工作台 |
| `电商AI平台/model-photo-demo-a.html` | `#modelPhotoDemoA` | 已补齐 |
| `电商AI平台/model-photo-demo-b.html` | `#modelPhotoDemoB` | 已补齐 |
| `电商AI平台/platform-cover.html` | `#platformCover` | 已补齐工作台 |
## 非视频接口对接
已对接:
- Auth注册、登录、退出、当前用户。
- 商品:列表、详情、创建、更新、删除。
- 项目:列表、创建、删除、流水线阶段操作。
- 资产:列表、上传。
- 计费:余额摘要、账本流水。
- 团队:成员只读列表。
- AI模型配置列表、AI 任务列表。
仍需后端补写接口后再做真实对接:
- 团队后台写操作:创建成员、编辑成员额度、重置密码、团队月限额。
- 支付/充值:支付订单创建、支付状态回调、人工入账。
- 图片工具真实生产接口:商品图、模特图、平台套图的独立任务创建、轮询、采用入库。
- 运营后台:用户、团队、任务、账单、模型、异常任务管理。
## 验收原则
1. 先验收页面还原和点击流。
2. 再验收非视频接口、额度、账本和后台管理。
3. 最后才测试真实生视频,包括 60s 多段生产、轮询、采用、导出和扣费回滚。

View File

@ -0,0 +1,734 @@
# AirShelf 功能开发对齐文档
> 版本v0.3
> 日期2026-05-29
> 目的:把 PRD、页面流程定稿、v1 原型、原版补充页面和后端架构统一成一份可开发、可验收、可排期的功能清单,避免实现时遗漏或误用过时逻辑。
## 1. 对齐源优先级
实现时按下面顺序判断,不一致时以上层为准:
| 优先级 | 来源 | 用途 |
| --- | --- | --- |
| 1 | `PRD.md` | 业务范围、V1/V2 边界、核心规则、验收目标 |
| 2 | `电商AI平台/页面流程定稿.md` | 页面流转、交互边界、阶段拆分、哪些能力不做 |
| 3 | `v1/*.html` | 当前核心原型,是在原版基础上的修改和增加;已覆盖页面以 v1 为准 |
| 4 | `电商AI平台/*.html` | 原版完整原型;用于补齐 v1 未覆盖页面、设计系统、登录注册、团队、设置和图片工具 |
| 5 | `core/ARCHITECTURE.md` | 后端架构、任务编排、额度、存储、部署策略 |
| 6 | `account.md` 与环境变量 | 测试资源与真实配置来源,不进入代码和公开文档 |
## 2. 当前必须统一的产品口径
| 口径 | 开发结论 |
| --- | --- |
| 后端技术 | Django + DRF + CeleryMySQL 持久化Redis 做缓存/任务/锁TOS 存储文件。 |
| AI 服务 | 全部接火山服务:对话、文生图/图生图、生视频、可能的脚本优化和素材理解。 |
| 60s 生成 | V1 真实实现 `4 x 15s` 多段视频,再进入 Stage5 拼接导出。每段独立任务、独立状态、独立重跑、独立额度记录。 |
| 项目新建 | 新建项目页只选商品和可选项目名。脚本来源、卖点、风格、时长等放到 Stage1。 |
| 分镜 | Stage3 是可选增强能力,不是生成视频的硬阻塞。无分镜时 Stage4 仍可用脚本和基础资产生成视频。 |
| 用户上传视频 | 不进入 Stage4。上传视频只作为 Stage5 的素材池,可用于替换、补位、剪辑和导出。 |
| 额度扣费 | 所有 AI 调用必须先预估、确认、冻结或记账;失败不实际扣费;成功落账可追溯。 |
| 运营后台 | V1 全量真实实现时必须有后台:用户/团队/项目/任务/资产/额度/账单/模型配置/异常重试。 |
| 原型取舍 | 已迁移到 `v1` 的页面以 `v1` 为准;`v1` 未覆盖的页面回看原版。复制项目、归档、批量生成、审核流、爆款复刻、直接发布等仍不进入 V1 主路径,除非 PRD 明确升级。 |
## 3. V1 功能总目标
V1 的目标不是只做静态界面,而是跑通一条真实可计费、可追踪、可运营的 AI 视频生产链路:
1. 用户注册并自动创建团队。
2. 团队管理员配置成员、月度额度、充值或分配额度。
3. 用户创建商品,维护商品图、卖点、品牌、类目等基础资料。
4. 用户基于商品创建项目。
5. 项目进入 5 阶段生产管线:脚本、基础资产、故事板、视频片段、拼接导出。
6. 真实调用火山模型完成脚本、图片、视频生产。
7. 60s 视频按 4 个 15s 段落并发/排队生成,支持失败重试和单段重跑。
8. Stage5 支持轻量剪辑、字幕、BGM、转场、导出 9:16 1080p MP4。
9. 所有产物自动入资产库,支持搜索、筛选、下载、复用。
10. 所有消耗进入额度账本,团队、成员、项目、任务都能追溯。
11. 运营后台可介入排障、补额度、重试任务、管理模型和查看成本。
## 4. 页面与功能地图
| 页面 | 原型文件 | 后端模块 | 优先级 | 必须实现 |
| --- | --- | --- | --- | --- |
| 登录/注册 | `电商AI平台/login.html`, `电商AI平台/register.html` | `accounts`, `teams` | P0 | 注册、登录、登出、创建团队、JWT/Session、基础权限。 |
| 工作台 | `v1/index.html` | `projects`, `billing`, `assets` | P1 | 项目概览、待处理、最近资产、额度摘要、快速入口。 |
| 商品库 | `v1/products.html` | `products` | P0 | 商品列表、搜索筛选、网格/列表、创建、编辑、删除、详情跳转。 |
| 商品创建 | `电商AI平台/product-create*.html` | `products`, `assets` | P0 | 图片上传、商品信息、卖点、类目、素材绑定、校验。 |
| 商品详情 | `电商AI平台/product-detail.html` | `products`, `assets`, `projects` | P1 | 商品资料、关联素材、关联项目、可编辑。 |
| 项目列表 | `v1/projects.html` | `projects`, `tasks` | P0 | 状态筛选、搜索、排序、项目卡片/列表、继续/查看/重试。 |
| 新建项目 | `v1/projects-new.html` | `projects`, `products` | P0 | 只选择商品与项目名,创建后进入 Stage1。 |
| 生产管线 | `v1/pipeline.html` | `projects`, `tasks`, `ai`, `assets`, `billing` | P0 | 5 阶段完整闭环,真实 AI 调用,状态机和任务编排。 |
| 资产库 | `v1/library.html` | `assets`, `storage` | P0 | 素材分类、搜索、筛选、上传、下载、删除、复用。 |
| 消费/账单 | `v1/account.html` | `billing`, `teams` | P0 | 余额、充值记录、项目账单、成员账单、额度规则、流水。 |
| 团队管理 | `电商AI平台/team.html` | `teams`, `accounts`, `billing` | P1 | 成员、角色、邀请、禁用、月度额度、权限矩阵。 |
| 运营后台 | 无独立静态原型,按 PRD 和架构补齐 | `ops`/Django Admin | P0 | 用户团队、任务、资产、账单、模型配置、失败重试、人工补偿。 |
| 设置/消息 | `电商AI平台/settings.html`, `电商AI平台/messages.html` | `accounts`, `notifications` | P2 | 账户设置、消息中心、通知状态。 |
| 图片工具 | `电商AI平台/asset-factory.html` 等 | `ai`, `assets` | P2 | 可作为后续独立工具,不影响主生产闭环。 |
## 5. P0 主生产闭环
### 5.1 账号与团队
前端需要:
- 注册页、登录页、登出入口。
- 当前用户信息、当前团队信息、当前角色展示。
- 未登录拦截,登录后回跳原目标页。
- 团队额度不足、权限不足、账号禁用等通用提示。
后端需要:
- 用户模型、团队模型、团队成员模型、角色权限。
- 注册时自动创建默认团队,并把注册用户设为团队管理员。
- 登录认证、刷新 token 或 session、密码重置预留。
- 权限中间件:团队隔离、角色能力、资源归属校验。
验收标准:
- 新用户注册后能进入工作台并拥有一个默认团队。
- 同一团队成员能看到团队资源,不同团队不能互相访问。
- 非管理员不能进行充值、成员额度配置、后台敏感操作。
### 5.2 商品库
前端需要:
- 商品列表:网格/列表切换、搜索、类目筛选、时间筛选、排序。
- 商品卡片:封面图、名称、类目、素材数量、关联视频数量、最近更新时间。
- 商品创建/编辑:商品图、标题、品牌、类目、卖点、规格、目标人群、备注。
- 图片上传:本地上传到 TOS生成可预览 asset。
- 删除保护:有关联项目时给出风险提示。
后端需要:
- `Product``ProductImage``ProductSellingPoint` 等模型。
- 商品 CRUD API、筛选分页 API。
- TOS 上传签名或后端中转上传。
- 商品与资产、项目的关联关系。
验收标准:
- 商品能创建、编辑、查询、删除。
- 项目创建时只能选择当前团队可用商品。
- 商品图片能进入资产库,并可在 Stage2 作为商品基础资产使用。
### 5.3 项目列表与新建
前端需要:
- 项目列表 tabs全部、进行中、生成中、已完成、失败。
- 项目搜索、筛选、排序。
- 项目卡片展示9:16 封面、商品、当前阶段、5 阶段进度、状态、最近更新时间。
- 操作:继续、查看、失败重试。
- 新建项目只选择商品和可选项目名,不放脚本来源、风格、时长等高级配置。
后端需要:
- `Project``ProjectStageState``ProjectProgress`
- 项目状态机:草稿、脚本中、资产中、分镜中、视频中、导出中、完成、失败。
- 项目创建 API、项目列表 API、项目详情 API、阶段推进 API。
验收标准:
- 从商品创建项目后进入 Stage1。
- 项目当前阶段和每阶段状态能准确回显。
- 失败项目能看到失败原因,并能按任务粒度重试。
### 5.4 Stage1 脚本
前端需要:
- 中区脚本/读秒分镜工作台。
- 右侧 AI 对话、历史版本。
- 底部 AI 输入框。
- 脚本来源入口AI 帮我写、我有脚本、一句话生成、复刻爆款入口置灰或隐藏。
- 商品卖点选择在 Stage1 完成。
- 脚本版本:生成、编辑、保存、采用、回滚。
后端需要:
- `ScriptVersion``ScriptSegment`
- 火山对话/文本模型适配器。
- Prompt 模板管理:商品信息、卖点、平台风格、时长目标、脚本结构。
- 脚本生成任务、脚本优化任务、版本记录。
- 消耗预估和账本记录。
验收标准:
- 可从商品信息生成脚本。
- 可手工粘贴脚本并结构化成多个段落。
- 可保存多个版本,采用一个版本进入 Stage2。
- AI 失败不扣费,并可重试。
### 5.5 Stage2 基础资产
前端需要:
- 三类资产顺序:商品、人物、场景。
- 商品资产:商品三联图为一张 16:9 图片,不拆成 3 个独立槽位。
- 人物资产AI 提取人物、生成 4 张候选肖像、选择 1 张、生成 16:9 三联图、采用。
- 场景资产:生成 4 张候选场景图、选择 1 张、采用。
- 支持重跑、版本历史、采用、预览。
- 人物资产可选择保存到人物库。
后端需要:
- `BaseAssetGroup``AssetVersion``AssetSelection`
- 火山生图/图生图模型适配器。
- 图片任务队列、并发限制、失败重试。
- 资产与项目阶段绑定。
- TOS 存储、缩略图、元数据。
验收标准:
- 三类基础资产都能真实生成并保存。
- 每次重跑保留历史,不覆盖已采用版本。
- Stage2 至少采用商品、人物、场景各一组后,可进入 Stage3 或跳过分镜进入 Stage4。
### 5.6 Stage3 故事板
前端需要:
- 故事板为可选阶段。
- 支持按脚本段落生成故事板图。
- Prompt 可编辑,但不做复杂聊天。
- 显示绑定资产标签:商品、人物、场景。
- 当基础资产变更时,提示建议重新生成故事板。
- 支持跳过故事板直接进入 Stage4。
后端需要:
- `StoryboardVersion``StoryboardFrame`
- 脚本段落到故事板帧的映射。
- 火山图片模型任务。
- 故事板版本、采用、重跑。
验收标准:
- 有故事板时Stage4 默认使用故事板作为视频生成参考。
- 无故事板时Stage4 能基于脚本和基础资产生成视频。
- 重跑故事板不影响已采用的视频片段,除非用户主动重新生成视频。
### 5.7 Stage4 视频片段
前端需要:
- 展示 4 个 15s 片段槽位,对应 60s 总视频。
- 每段显示状态:未开始、排队、生成中、成功、失败、已采用。
- 每段可编辑 prompt、生成、重跑、查看历史、采用。
- 支持并发生成,但前端必须展示每段独立进度。
- 单次生成一个候选视频,历史版本中保留多次结果。
- 用户上传视频不出现在 Stage4。
后端需要:
- `VideoSegment``VideoSegmentVersion`
- 火山生视频模型适配器。
- Celery 任务:提交、轮询、下载、转存 TOS、回调处理。
- 任务幂等:同一段重试不产生重复扣费。
- 并发控制:团队级、用户级、模型级、全局级。
- 失败原因标准化:额度、模型、超时、内容安全、存储、未知。
验收标准:
- 60s 视频能由 4 段 15s 视频构成。
- 某一段失败时,不影响其他已成功段;可单段重跑。
- 每段成功后自动入资产库,并能被 Stage5 使用。
- 模型失败不实际扣费;模型成功但后处理失败要进入可补偿状态。
### 5.8 Stage5 拼接导出
前端需要:
- 轻量剪辑器素材池、主轨道、字幕、BGM、转场、预览、导出。
- 素材池包含 AI 视频片段、用户上传视频、资产库视频。
- 单主轨,支持自动放置、拖拽排序、删除、替换、裁剪。
- 支持从脚本生成字幕,并允许编辑样式和文本。
- 支持 BGM 选择、音量设置。
- 导出结果页:预览、下载、复制链接、查看资产、继续编辑、返回项目。
后端需要:
- `Timeline``TimelineClip``SubtitleTrack``BgmTrack``ExportJob`
- FFmpeg 拼接、转码、字幕烧录或外挂字幕策略。
- 输出规格9:16、1080p、MP4。
- 导出任务队列、进度、失败重试。
- 导出文件转存 TOS生成资产库记录。
验收标准:
- 4 段视频能拼接成一个 60s 成片。
- 用户上传素材能加入 Stage5 并参与导出。
- 导出成功后自动进入资产库的成片分类。
- 导出失败能保留 timeline 并允许重试。
## 6. P0 额度与账本
前端需要:
- 生成前显示本次预估消耗和账户余额。
- 额度不足时阻止提交,并给管理员充值或申请额度入口。
- 账单页按项目、成员、账单维度查看消耗。
- 团队页可配置成员月度额度。
后端需要:
- 四层额度:团队余额、成员月度额度、项目预算、单任务预估/实扣。
- `CreditAccount``CreditLedger``QuotaPolicy``CreditReservation`
- 账本必须只追加,不直接覆盖历史。
- 支持预估、冻结、成功扣费、失败释放、人工调整。
- 每条账本关联:团队、用户、项目、任务、模型、输入输出资产。
验收标准:
- 所有 AI 与导出任务都有账本记录。
- 失败任务不会消耗最终余额。
- 管理员能看到团队、成员、项目三个维度的消耗。
- 后台人工补偿、充值、扣减都可追溯。
## 7. P0 资产库与存储
前端需要:
- Tabs人物、场景、商品图、成片、我的上传、未分类。
- 搜索、类型筛选、来源筛选、排序。
- 上传素材、预览、下载、删除。
- 在项目中复用资产。
后端需要:
- `Asset``AssetFile``AssetTag``AssetUsage`
- 文件类型image、video、audio、subtitle、document。
- 来源upload、ai_generated、exported、system。
- TOS object key、bucket、content type、size、duration、resolution、checksum。
- 缩略图、预览地址、临时下载链接。
验收标准:
- AI 生成图、视频片段、最终成片都自动入库。
- 用户上传文件可用于 Stage5。
- 资产被项目使用时有使用记录,删除时能提示风险。
## 8. P0 运营后台
运营后台可以先基于 Django Admin 扩展后续再做独立后台页面。V1 全量真实 AI 必须具备这些能力:
| 模块 | 必须能力 |
| --- | --- |
| 用户与团队 | 查询、禁用、角色、团队归属、成员额度。 |
| 商品与项目 | 查询、状态查看、异常项目定位。 |
| AI 任务 | 查看入参摘要、模型、状态、耗时、失败原因、重试、取消。 |
| 资产 | 查看文件、归属、来源、TOS key、可用性检查。 |
| 账本 | 充值、扣费、释放、人工补偿、流水审计。 |
| 模型配置 | 模型 endpoint、能力类型、单价、限流、开关、降级策略。 |
| 系统配置 | Prompt 模板、BGM 库、字幕样式、导出参数。 |
验收标准:
- 任一用户反馈“生成失败”时,运营能在后台定位到项目、阶段、任务、模型错误和账本状态。
- 后台能安全重试任务,不产生重复扣费。
- 模型临时不可用时能关闭入口或切换备用配置。
## 9. 状态机与任务状态
项目阶段状态:
| 状态 | 含义 |
| --- | --- |
| `not_started` | 还未进入阶段。 |
| `draft` | 有编辑内容,但未确认提交。 |
| `queued` | 已进入队列。 |
| `running` | 正在执行。 |
| `succeeded` | 当前阶段已完成。 |
| `failed` | 当前阶段失败,可查看原因。 |
| `skipped` | 用户主动跳过,比如 Stage3。 |
| `needs_review` | 任务成功但需要用户选择或确认采用。 |
AI 任务状态:
| 状态 | 含义 |
| --- | --- |
| `created` | 任务已创建但未扣/未冻结。 |
| `reserved` | 已冻结或记录预估额度。 |
| `submitted` | 已提交火山。 |
| `polling` | 等待火山结果。 |
| `postprocessing` | 下载、转存、转码、生成缩略图。 |
| `succeeded` | 完成并落资产。 |
| `failed` | 失败并释放额度。 |
| `compensating` | 外部成功但本地后处理失败,需要补偿。 |
| `cancelled` | 用户或系统取消。 |
状态要求:
- 前端所有按钮必须根据状态禁用或显示正确动作。
- 后端所有阶段推进必须校验前置条件。
- Celery 任务必须幂等,重复执行不会重复创建资产或重复扣费。
## 10. API 开发清单
建议先按下面 API 分组开发,保证前后端可以并行:
| 分组 | API 能力 |
| --- | --- |
| Auth | 注册、登录、登出、当前用户、当前团队。 |
| Team | 团队详情、成员列表、邀请、禁用、角色、月度额度。 |
| Product | 商品 CRUD、图片上传、卖点、详情、关联项目。 |
| Project | 项目 CRUD、列表筛选、详情、阶段状态、失败重试。 |
| Script | 生成脚本、优化脚本、保存版本、采用版本。 |
| Base Assets | 生成商品图/人物/场景、候选列表、采用、重跑。 |
| Storyboard | 生成故事板、编辑 prompt、采用、跳过。 |
| Video | 生成片段、查询进度、重跑、采用、历史版本。 |
| Timeline | 素材池、轨道、字幕、BGM、保存 timeline。 |
| Export | 提交导出、查询进度、下载、复制链接、重试。 |
| Asset | 资产列表、筛选、上传、预览、下载、删除、复用。 |
| Billing | 余额、预估、确认、流水、账单筛选、人工调整。 |
| Ops | 任务查询、模型配置、重试、补偿、系统配置。 |
## 11. 数据模型开发清单
P0 最小模型集合:
- `User`
- `Team`
- `TeamMember`
- `Role`
- `Product`
- `ProductImage`
- `ProductSellingPoint`
- `Project`
- `ProjectStage`
- `ScriptVersion`
- `ScriptSegment`
- `Asset`
- `AssetFile`
- `AssetUsage`
- `BaseAssetGroup`
- `StoryboardVersion`
- `StoryboardFrame`
- `VideoSegment`
- `VideoSegmentVersion`
- `Timeline`
- `TimelineClip`
- `SubtitleTrack`
- `BgmTrack`
- `ExportJob`
- `AITask`
- `ModelProvider`
- `ModelConfig`
- `CreditAccount`
- `CreditLedger`
- `CreditReservation`
- `QuotaPolicy`
关键约束:
- 所有业务表必须带 `team_id`,必要时带 `created_by`
- 所有 AI 产物必须能追溯到 `project_id``task_id``model_config_id`
- 所有费用必须通过账本记录,不直接修改余额后丢失原因。
- 所有 TOS 文件必须有资产记录或临时文件清理机制。
## 12. 前端实现清单
前端不应该直接复制静态 HTML而应该抽象为真实组件
正式产品入口必须是 React History 路由,不使用 `*.html` 作为业务页面地址。当前 `/exact/*.html` 的用途是保存设计稿镜像、跑像素级视觉回归和子页面校对;真实页面使用 `/login``/register``/dashboard``/products``/products/new``/products/:id``/projects``/projects/new``/pipeline/:id` 等 React 路由。
| 组件/区域 | 开发要求 |
| --- | --- |
| App Shell | 顶部/侧边导航、当前团队、用户菜单、未读消息、额度提示。 |
| Resource Picker | 商品选择、资产选择、项目素材选择复用同一交互。 |
| Stage Stepper | 5 阶段进度,支持点击已完成阶段回看。 |
| Task Progress | 轮询任务状态,显示排队、进度、失败原因、重试。 |
| Version Panel | 脚本、图片、故事板、视频都复用版本历史/采用模式。 |
| Quota Modal | 生成前统一确认额度,不在每个页面重复造逻辑。 |
| Upload Widget | 图片/视频/音频上传到 TOS支持进度和失败重试。 |
| Asset Card | 资产预览、类型、来源、下载、删除、复用。 |
| Timeline Editor | Stage5 主轨、素材池、字幕、BGM、导出。 |
| Empty/Error States | 首次使用、无数据、额度不足、模型失败、网络失败。 |
原型差异注意:
- 已迁移到 `v1` 的页面,视觉与信息层级优先参考 `v1`
- `v1` 未覆盖的登录注册、商品详情、团队、设置、消息、图片工具等,参考 `电商AI平台` 原版页面补齐。
- 如果 `v1/projects-new.html` 仍保留旧的多步配置,开发时按页面流程定稿收敛,只保留商品和项目名。
- 复制、归档、批量、审核流等非 V1 主路径能力,不因原型里出现就默认开发。
- `pipeline.html` 的视觉和结构可参考,但真实实现必须以 API 状态为准。
- 图片工具页可以复用模型能力,但不能阻塞主生产链路。
## 13. 后端实现清单
后端优先级:
1. Django 项目基础、环境配置、MySQL、Redis、Celery、TOS、日志。
2. 账号、团队、权限、商品、资产、项目基础模型和 API。
3. 额度账本与任务系统先落地,再接火山模型。
4. 火山模型适配层:文本、图片、视频统一接口,支持模型配置化。
5. 生产管线状态机Stage1 到 Stage5 的前置校验、推进和回滚。
6. Celery 异步任务AI 提交/轮询/转存/后处理/账本落账。
7. Stage5 FFmpeg 导出服务。
8. Django Admin 运营后台增强。
9. 审计日志、告警、失败补偿、清理任务。
企业级要求:
- AI 调用不能写死模型名和价格,必须走 `ModelConfig`
- 任务必须幂等,支持重试、取消和补偿。
- 额度扣费和任务成功必须在事务边界内保持一致。
- TOS 转存成功但数据库失败时,要有补偿或清理。
- 所有后台操作必须有审计日志。
- 所有外部 API key 只从环境变量读取。
## 14. 验收矩阵
| 编号 | 验收项 | 结果要求 |
| --- | --- | --- |
| A01 | 注册登录 | 新用户注册后自动创建团队并进入工作台。 |
| A02 | 商品创建 | 可上传商品图、填写信息、保存并在商品库搜索到。 |
| A03 | 项目创建 | 选择商品后创建项目,进入 Stage1。 |
| A04 | 脚本生成 | 调火山文本模型生成脚本,支持保存版本和采用。 |
| A05 | 基础资产 | 商品、人物、场景三类资产真实生成并采用。 |
| A06 | 故事板 | 可生成、采用、跳过,跳过不阻塞视频生成。 |
| A07 | 60s 视频 | 生成 4 个 15s 片段,支持单段失败重试。 |
| A08 | 拼接导出 | 4 段视频导出为 9:16 1080p MP4。 |
| A09 | 资产入库 | AI 图片、视频片段、成片自动进入资产库。 |
| A10 | 上传素材 | 用户上传视频能进入 Stage5 并参与导出。 |
| A11 | 额度预估 | 每次 AI 生成前展示预估消耗并校验余额。 |
| A12 | 失败不扣费 | 模型失败、超时、内容拦截时不最终扣费。 |
| A13 | 账单追溯 | 能按团队、成员、项目、任务查看费用流水。 |
| A14 | 后台排障 | 后台能查任务失败原因并安全重试。 |
| A15 | 团队隔离 | 不同团队无法访问彼此商品、项目、资产和账单。 |
## 15. 首轮开发建议
为了最快证明系统可行,建议第一轮只做一条“真实最小闭环”,但所有核心架构都按全量方案预留:
1. 登录/注册、团队、商品、资产、项目基础。
2. Stage1 真实脚本生成。
3. Stage2 真实生成一组商品/人物/场景基础资产。
4. Stage4 真实生成 1 个 15s 视频片段。
5. Stage5 用 1 个片段导出 MP4。
6. 额度账本贯穿上述每一步。
7. 运营后台能查看和重试任务。
第二轮扩展到完整 60s
1. Stage4 扩展为 4 个片段并行/排队生成。
2. Stage5 拼接 4 段并支持字幕、BGM、转场。
3. 补齐 Stage3 故事板。
4. 补齐项目列表、账单页、团队管理、资产库高级筛选。
## 16. 开发完成后的真实数据测试计划
本节参考 `/Users/maidong/Desktop/zyc/github/AI-Express/项目角色agent/test-agent.md` 的测试 Agent 规范,并结合 AirShelf 的真实 AI、额度、TOS、Celery 异步任务场景做项目化落地。
开发完成后的验收不能只看接口和页面静态效果,必须用测试环境真实资源、真实数据、真实浏览器点击,把主流程跑穿。这里的“真实”指:
- 使用测试 MySQL、Redis、TOS、火山模型不用 mock 代替核心外部依赖。
- 用 Playwright 或同类工具启动真实浏览器,按用户视角点击、输入、上传、等待和下载。
- 每次测试生成独立 `run_id`数据库记录、TOS object key、日志、账本、任务都能追踪到同一轮测试。
- 测试脚本不能直接调用业务 API 把项目状态改到下一步;只允许用 seed/cleanup 脚本准备和清理测试数据。
### 16.0 测试 Agent 执行规范
测试执行时按代码测试、浏览器实操、真实端到端、浏览器真实模拟、测试报告五层推进:
| 层级 | 名称 | 目标 | AirShelf 要求 |
| --- | --- | --- | --- |
| A | 代码层测试 | 验证后端、前端、任务代码基本正确。 | Django/DRF 单元测试、API 测试、Celery 任务测试、前端组件测试。 |
| B | 浏览器实操 | 打开真实页面点击、输入、hover、拖拽、移动端检查。 | 每次关键操作后检查 console error/warning 并截图。 |
| D | 真实端到端 | 验证真实环境和真实数据流通。 | MySQL、Redis、TOS、火山模型、FFmpeg、Celery 全部接真实测试资源。 |
| E | Playwright 真实模拟 | 验证用户在浏览器里实际看到和操作的结果。 | 禁止 mock API用真实账号、真实商品、真实上传、真实 AI 结果。 |
| C | 测试报告 | 输出可复查证据和 Bug 清单。 | 没有截图、trace、控制台日志、任务 ID 和账本 ID 的结论不算通过。 |
执行硬规则:
- 代码测试通过不等于测试完成;必须有 Playwright 浏览器截图验证。
- 每次点击、输入、提交、状态切换后都要检查控制台错误。
- 截图必须人工或视觉模型审查,不能只判断 DOM 存在。
- 初始判定默认是 `NEEDS WORK`,只有证据充分才给 `PASS`
- 报告里如果声称 0 问题,需要追加一轮复测。
- P0 Bug 发现后立即反馈开发修复,不等全量测试跑完。
- 同一个模块最多测试 3 轮;第 3 轮仍失败则进入升级处理。
本机执行建议:
- 优先复用全局 Playwright CLI不要重复项目级安装。
- 跑测试前先确认 `playwright --version` 和浏览器驱动是否存在。
- 缺浏览器驱动时只安装必需浏览器,优先 `chromium`
- 可回归测试写成 Playwright spec探索性排查可用 Playwright MCP 或交互式工具。
### 16.1 测试环境要求
| 类型 | 要求 |
| --- | --- |
| 数据库 | 使用 `account.md` 中的测试 MySQL 配置,单独测试库或测试 schema。 |
| Redis | 使用测试 Redis按正式 DB index 规划区分 cache、Celery broker、result backend、lock。 |
| TOS | 使用测试 bucket 或测试前缀,例如 `e2e/{run_id}/...`,测试结束可清理。 |
| 火山模型 | 使用真实 ARK/生图/生视频配置模型名、endpoint、价格来自 `ModelConfig`。 |
| Worker | Celery worker、Celery beat、FFmpeg、回调或轮询任务必须真实启动。 |
| 前端 | 使用接近正式构建的前端产物,不用开发模式里的假数据。 |
| 日志 | 后端日志、Celery 日志、浏览器 trace、截图、视频、导出文件都要保留到测试报告。 |
### 16.2 真实测试数据
每轮 E2E 测试用 seed 脚本准备下面数据:
| 数据 | 内容 |
| --- | --- |
| 测试团队 | `E2E Team {run_id}`,包含 owner、member、no_quota_user、disabled_user。 |
| 测试额度 | owner 有足够额度member 有月度额度no_quota_user 额度为 0。 |
| 测试商品 | 至少 3 个真实商品样例:护肤品、小家电、服饰;每个包含真实商品图、标题、品牌、类目、卖点。 |
| 测试素材 | 上传 1 个短视频、1 张商品图、1 张场景图、1 个 BGM 样例。 |
| 模型配置 | 文本、图片、视频、导出任务均有启用状态、单价、限流和能力类型。 |
| 管理员账号 | 可登录 Django Admin 或运营后台,验证任务、账本和补偿。 |
测试数据要求:
- 商品图片和上传视频必须是真文件,能上传到 TOS 并回显预览。
- 商品标题、卖点、脚本输入要覆盖中文、数字、标点和较长文本。
- 所有 seed 数据带 `run_id`,避免和人工测试数据混在一起。
### 16.3 浏览器 E2E 主流程
这些用例必须通过真实浏览器点击完成:
| 编号 | 场景 | 浏览器动作 | 验收结果 |
| --- | --- | --- | --- |
| E01 | 注册登录 | 打开注册页,填写账号,提交,进入工作台。 | 自动创建团队,导航和当前用户信息正确。 |
| E02 | 创建商品 | 进入商品库,点击新建,上传商品图,填写信息并保存。 | 商品出现在商品库,图片可预览,数据库和 TOS 有记录。 |
| E03 | 创建项目 | 从项目列表点击新建,选择商品,填写项目名,提交。 | 创建项目并进入 Stage1项目绑定正确商品。 |
| E04 | 生成脚本 | 在 Stage1 选择卖点,输入需求,点击 AI 生成,采用脚本版本。 | 真实调用文本模型,脚本版本落库,账本有预估和实扣。 |
| E05 | 生成基础资产 | 在 Stage2 依次生成商品、人物、场景资产,选择候选并采用。 | 真实生成图片TOS 有文件,资产库有记录,项目阶段可推进。 |
| E06 | 故事板 | Stage3 生成故事板并采用;另跑一条项目验证跳过故事板。 | 生成和跳过两条路径都能进入 Stage4。 |
| E07 | 生成 60s 视频 | Stage4 点击生成 4 个 15s 片段,等待完成,失败段单独重试。 | 4 段视频独立状态正确,成功片段入库,失败不扣最终费用。 |
| E08 | 拼接导出 | Stage5 自动放置片段,上传额外视频,添加字幕/BGM点击导出。 | 导出 9:16 1080p MP4能预览、下载、复制链接。 |
| E09 | 资产回查 | 进入资产库,按成片/视频/图片筛选,搜索本轮项目名。 | AI 产物、上传素材、最终成片都能找到并下载。 |
| E10 | 账单回查 | 进入消费页,按项目和成员筛选本轮流水。 | 每个 AI 任务和导出任务都有账本记录,余额变化正确。 |
| E11 | 团队权限 | 用 member、no_quota_user、disabled_user 分别登录访问同一流程。 | 权限、额度不足、账号禁用提示正确,不能越权。 |
| E12 | 运营后台 | 管理员打开后台,搜索本轮项目和任务,查看日志并触发安全重试。 | 能定位任务、资产、账本,重试不会重复扣费。 |
浏览器测试规则:
- 使用 Chromium 作为必跑浏览器;上线前增加 WebKit/Safari 兼容验证。
- 桌面视口必跑,移动视口至少覆盖项目列表、生产管线、资产库、账单页。
- 每次关键操作后检查 console error/warning。
- 每个关键阶段保存截图;失败时保存 Playwright trace、console log、network log。
- 截图中出现空白、错位、遮挡、placeholder 数据、异常报错,一律判失败。
- 长任务要通过 UI 轮询等待,不允许测试脚本直接改数据库状态。
- 下载的最终 MP4 要用 `ffprobe` 校验分辨率、时长、编码和文件大小。
### 16.4 异步任务与失败恢复测试
必须单独测试这些异常路径:
| 场景 | 验收要求 |
| --- | --- |
| 火山文本失败 | Stage1 显示失败原因,账本释放,支持重试。 |
| 图片生成超时 | Stage2 单项失败不污染已采用资产,支持重跑。 |
| 视频单段失败 | Stage4 只重跑失败段,其他段保持成功状态。 |
| TOS 转存失败 | 任务进入 `compensating``failed`,后台可补偿处理。 |
| Celery worker 重启 | 已提交任务能继续轮询或恢复,不能重复扣费。 |
| Redis 锁过期 | 同一任务重复提交时保持幂等。 |
| 导出失败 | Timeline 保存不丢失,用户可重新导出。 |
| 额度不足 | 前端阻止提交,后台没有创建实际 AI 任务。 |
### 16.5 账本一致性测试
每轮 E2E 完成后必须自动核对:
- 团队余额 = 初始余额 + 充值/调整 - 成功任务实扣。
- `CreditLedger` 每条流水都有 team、user、project、task、model 或 export job。
- 失败任务没有最终扣费;如果有冻结记录,必须有释放记录。
- 重试任务不能重复扣同一次失败费用。
- 后台人工补偿必须产生审计日志。
- 页面展示余额、数据库余额、账本汇总三者一致。
### 16.6 文件与成片质量测试
每个生成文件都要校验:
- TOS object 存在content type 正确,文件大小大于 0。
- 图片能打开,缩略图能显示。
- 视频片段能播放,时长接近 15s。
- 最终成片为 9:16、1080p、MP460s 成片时长允许合理浮动。
- 下载链接有过期时间,不暴露永久私有地址。
- 删除测试数据后TOS 测试前缀可被清理,不留下大量临时文件。
### 16.7 发布前测试门禁
进入正式部署前,必须满足:
- P0 浏览器 E2E 全部通过。
- P0 API 集成测试全部通过。
- 账本一致性核对为 0 差异。
- 没有 `compensating``running``reserved` 状态的遗留测试任务。
- TOS 没有孤儿文件,或孤儿文件已进入清理队列。
- 后台可查到本轮测试的项目、任务、资产、账本和导出记录。
- 测试报告包含run_id、测试账号、项目 ID、任务 ID、账本 ID、导出文件地址、截图和失败 trace。
### 16.8 测试报告与交接格式
每轮测试必须产出可追溯报告,建议目录:
- `test-reports/{run_id}/summary.md`
- `test-reports/{run_id}/screenshots/`
- `test-reports/{run_id}/traces/`
- `test-reports/{run_id}/videos/`
- `test-reports/{run_id}/logs/`
报告必须包含:
| 模块 | 内容 |
| --- | --- |
| 测试概览 | run_id、环境、前端版本、后端版本、测试账号、测试时间。 |
| 数据策略 | 全真实、真实+seed、mock 降级;必须说明原因。 |
| 服务状态 | 前端、后端、MySQL、Redis、Celery、TOS、火山模型、FFmpeg。 |
| 用例结果 | E01-E12 每一步的通过/失败、截图、任务 ID、账本 ID。 |
| 控制台错误 | 页面、操作、错误内容、严重度、截图。 |
| 网络/API 错误 | URL、状态码、响应摘要、复现步骤。 |
| Bug 清单 | 严重度、描述、定位、复现步骤、截图或 trace。 |
| 账本核对 | 初始余额、预估、冻结、实扣、释放、最终余额。 |
| 文件核对 | TOS key、文件大小、content type、视频时长、分辨率。 |
| 结论 | `QA PASS``QA FAIL``INCOMPLETE``ESCALATION`。 |
判定标准:
- `QA PASS`P0/P1 全部通过,账本 0 差异,浏览器截图和 trace 证据完整。
- `QA FAIL`:存在阻塞 Bug必须带复现步骤和证据返回开发。
- `INCOMPLETE`:环境未跑通、缺截图、缺真实数据、或跳过浏览器测试。
- `ESCALATION`:同一模块第 3 轮仍失败,或外部服务/架构问题阻塞继续测试。
交接给开发时,最后必须附结构化摘要:
```xml
<task-completion>
<status>completed | partial | failed</status>
<summary>一句话说明本轮测试结论</summary>
<deliverables>
- summary.md: done | partial | skipped
- screenshots: done | partial | skipped
- traces: done | partial | skipped
</deliverables>
<self-check-results>
- [x] 真实浏览器点击: PASS
- [x] 控制台错误检查: PASS
- [x] 真实数据验证: PASS
- [x] 账本一致性核对: PASS
</self-check-results>
<escalations>无,或列出需要上报的问题</escalations>
</task-completion>
```
## 17. 待确认项
这些项不阻塞开发,但进入正式计费和对外交付前需要确认:
- 火山各模型的正式 endpoint、并发限制、回调能力、计费口径。
- 60s 视频默认脚本分段策略:固定 4 段,还是按脚本语义自动切 4 段。
- 失败重试的免费次数和计费边界。
- BGM 来源:系统内置、用户上传、还是第三方库。
- 字幕样式默认模板数量。
- 成片是否加水印,测试环境和正式环境是否不同。
- 内容安全策略:由火山模型拦截、平台自审、还是两者结合。
- 导出文件保留周期和临时文件清理周期。

26
core/backend/.env.example Normal file
View File

@ -0,0 +1,26 @@
DJANGO_SETTINGS_MODULE=airshelf.settings.development
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
DB_ENGINE=mysql
DB_NAME=airshelf_dev
DB_USER=airshelf
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
DB_BIND_ADDRESS=
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
REDIS_LOCK_URL=redis://127.0.0.1:6379/3
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
TOS_BUCKET=airshelf
TOS_ACCESS_KEY_ID=change-me
TOS_SECRET_ACCESS_KEY=change-me
VOLCANO_ARK_API_KEY=change-me
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3

44
core/backend/README.md Normal file
View File

@ -0,0 +1,44 @@
# AirShelf Backend
All backend code lives under `AirShelf/core/backend` by project decision.
## Local bootstrap
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
Start workers in separate terminals:
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
source .venv/bin/activate
celery -A airshelf worker -l info
```
`ffmpeg` must be available on `PATH` for Stage5 export jobs.
## Runtime layout
- Django project: `airshelf`
- Domain apps: `apps/*`
- Settings module: `airshelf.settings.development`
- Celery app: `airshelf.celery`
Secrets must be supplied by environment variables or `.env`; never commit values from `account.md`.
## Useful commands
```bash
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py migrate
python manage.py bootstrap_volcano_models
python manage.py test apps.accounts apps.projects apps.billing
```

View File

@ -0,0 +1,7 @@
try:
import pymysql
pymysql.install_as_MySQLdb()
except Exception:
pass

View File

@ -0,0 +1,9 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_asgi_application()

View File

@ -0,0 +1,11 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
app = Celery("airshelf")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,175 @@
from pathlib import Path
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
def env(name: str, default: str | None = None) -> str | None:
return os.getenv(name, default)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(name: str, default: str = "") -> list[str]:
value = os.getenv(name, default)
return [item.strip() for item in value.split(",") if item.strip()]
SECRET_KEY = env("DJANGO_SECRET_KEY", "airshelf-dev-insecure-key")
DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"apps.common",
"apps.accounts",
"apps.assets",
"apps.products",
"apps.projects",
"apps.ai",
"apps.billing",
"apps.ops",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "airshelf.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "airshelf.wsgi.application"
ASGI_APPLICATION = "airshelf.asgi.application"
if env("DB_ENGINE", "sqlite") == "mysql":
mysql_options = {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
}
if env("DB_BIND_ADDRESS"):
mysql_options["bind_address"] = env("DB_BIND_ADDRESS")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": env("DB_NAME", "airshelf"),
"USER": env("DB_USER", "airshelf"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST", "127.0.0.1"),
"PORT": env("DB_PORT", "3306"),
"OPTIONS": mysql_options,
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"PAGE_SIZE": 20,
}
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"),
}
}
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TIMEZONE = TIME_ZONE
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
TOS = {
"endpoint": env("TOS_ENDPOINT"),
"bucket": env("TOS_BUCKET"),
"access_key_id": env("TOS_ACCESS_KEY_ID"),
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
}
VOLCANO = {
"ark_api_key": env("VOLCANO_ARK_API_KEY"),
"ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")

View File

@ -0,0 +1,5 @@
from .base import * # noqa: F403
DEBUG = True

View File

@ -0,0 +1,8 @@
from .base import * # noqa: F403
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

View File

@ -0,0 +1,13 @@
from .base import * # noqa: F403
DEBUG = False
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

View File

@ -0,0 +1,17 @@
from django.contrib import admin
from django.urls import include, path
from apps.common.views import health_check
urlpatterns = [
path("admin/", admin.site.urls),
path("api/health/", health_check, name="health-check"),
path("api/auth/", include("apps.accounts.urls")),
path("api/products/", include("apps.products.urls")),
path("api/assets/", include("apps.assets.urls")),
path("api/projects/", include("apps.projects.urls")),
path("api/billing/", include("apps.billing.urls")),
path("api/ai/", include("apps.ai.urls")),
path("api/ops/", include("apps.ops.urls")),
]

View File

@ -0,0 +1,9 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_wsgi_application()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import Team, TeamMember, User
@admin.register(User)
class AirShelfUserAdmin(UserAdmin):
list_display = ("username", "email", "status", "is_staff", "date_joined")
list_filter = ("status", "is_staff", "is_superuser")
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "status", "created_at")
search_fields = ("name", "owner__username", "owner__email")
list_filter = ("status",)
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
list_display = ("team", "user", "role", "status", "monthly_credit_limit")
search_fields = ("team__name", "user__username", "user__email")
list_filter = ("role", "status")

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View File

@ -0,0 +1,245 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("phone", models.CharField(blank=True, max_length=32)),
("avatar_url", models.URLField(blank=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Team",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_teams",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TeamMember",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("admin", "Admin"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=24,
),
),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("invited", "Invited"),
("disabled", "Disabled"),
],
default="active",
max_length=24,
),
),
(
"monthly_credit_limit",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_memberships",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("team", "user")},
},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,59 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from apps.common.models import TimeStampedModel
class User(AbstractUser):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
phone = models.CharField(max_length=32, blank=True)
avatar_url = models.URLField(blank=True)
@property
def is_disabled(self) -> bool:
return self.status == self.Status.DISABLED
class Team(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name="owned_teams")
def __str__(self) -> str:
return self.name
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
INVITED = "invited", "Invited"
DISABLED = "disabled", "Disabled"
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="members")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships")
role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
unique_together = [("team", "user")]
def __str__(self) -> str:
return f"{self.team} / {self.user} / {self.role}"

View File

@ -0,0 +1,68 @@
from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import Team, TeamMember, User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
read_only_fields = ["id", "status"]
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
read_only_fields = ["id", "team", "user", "status"]
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(min_length=8, write_only=True)
email = serializers.EmailField(required=False, allow_blank=True)
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("username already exists")
return value
def create(self, validated_data):
from decimal import Decimal
from django.conf import settings
from django.db import transaction
with transaction.atomic():
user = User.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data.get("email", ""),
)
team = Team.objects.create(
name=validated_data.get("team_name") or f"{user.username}'s Team",
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(
team=team,
balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)),
)
return {"user": user, "team": team}
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)

View File

@ -0,0 +1,29 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.billing.models import CreditAccount
class AuthApiTests(TestCase):
def test_register_creates_user_team_member_credit_account_and_token(self):
client = APIClient()
response = client.post(
"/api/auth/register/",
{
"username": "new-owner",
"password": "strong-password",
"email": "owner@example.com",
"team_name": "Launch Team",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertIn("token", response.data)
user = User.objects.get(username="new-owner")
team = Team.objects.get(name="Launch Team")
self.assertEqual(team.owner, user)
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
self.assertTrue(CreditAccount.objects.filter(team=team).exists())

View File

@ -0,0 +1,14 @@
from django.urls import path
from .views import login, logout, me, register, team_member_detail, team_member_password, team_members
urlpatterns = [
path("register/", register, name="auth-register"),
path("login/", login, name="auth-login"),
path("logout/", logout, name="auth-logout"),
path("me/", me, name="auth-me"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
]

View File

@ -0,0 +1,170 @@
from django.contrib.auth import authenticate
from django.db import transaction
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import TeamMember, User
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
def auth_payload(user, team, token):
return {
"token": token.key,
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
}
@api_view(["POST"])
@permission_classes([])
def register(request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([])
def login(request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["username"],
password=serializer.validated_data["password"],
)
if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
return Response(auth_payload(user, team, token))
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout(request):
Token.objects.filter(user=request.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def me(request):
team = get_current_team(request.user)
return Response(
{
"user": UserSerializer(request.user).data,
"team": TeamSerializer(team).data,
}
)
def normalize_member_role(role):
if role == "super":
return TeamMember.Role.OWNER
if role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN, TeamMember.Role.MEMBER, TeamMember.Role.VIEWER}:
return role
return TeamMember.Role.MEMBER
def can_manage_team(user, team):
member = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
return Response(TeamMemberSerializer(members, many=True).data)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
username = str(request.data.get("username") or "").strip()
password = str(request.data.get("password") or "").strip()
if not username:
return Response({"username": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["username already exists"]}, status=status.HTTP_400_BAD_REQUEST)
email = str(request.data.get("email") or "").strip() or f"{username}@airshelf.local"
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN
with transaction.atomic():
user = User.objects.create_user(username=username, password=password, email=email)
user.first_name = str(request.data.get("name") or "").strip()
user.save(update_fields=["first_name"])
member = TeamMember.objects.create(
team=team,
user=user,
role=role,
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
)
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsAuthenticated])
def team_member_detail(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner cannot be changed"}, status=status.HTTP_400_BAD_REQUEST)
if request.method == "DELETE":
user = member.user
member.delete()
if not TeamMember.objects.filter(user=user).exists():
user.status = User.Status.DISABLED
user.save(update_fields=["status"])
Token.objects.filter(user=user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
role = request.data.get("role")
if role:
member.role = normalize_member_role(role)
if member.role == TeamMember.Role.OWNER:
member.role = TeamMember.Role.ADMIN
if "monthly_credit_limit" in request.data or "monthly" in request.data:
member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0
name = str(request.data.get("name") or "").strip()
if name:
member.user.first_name = name
member.user.save(update_fields=["first_name"])
member.save(update_fields=["role", "monthly_credit_limit", "updated_at"])
return Response(TeamMemberSerializer(member).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_member_password(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner password cannot be reset here"}, status=status.HTTP_400_BAD_REQUEST)
password = str(request.data.get("password") or "").strip()
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
member.user.set_password(password)
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,26 @@
from django.contrib import admin
from .models import AITask, ModelConfig, ModelProvider
@admin.register(ModelProvider)
class ModelProviderAdmin(admin.ModelAdmin):
list_display = ("name", "display_name", "status", "updated_at")
search_fields = ("name", "display_name")
list_filter = ("status",)
@admin.register(ModelConfig)
class ModelConfigAdmin(admin.ModelAdmin):
list_display = ("provider", "name", "capability", "unit_price", "status", "rate_limit_per_minute")
search_fields = ("provider__name", "name", "display_name")
list_filter = ("capability", "status")
@admin.register(AITask)
class AITaskAdmin(admin.ModelAdmin):
list_display = ("id", "team", "project", "task_type", "status", "model_config", "actual_cost", "updated_at")
search_fields = ("idempotency_key", "provider_task_id", "project__name", "team__name")
list_filter = ("task_type", "status", "model_config__capability")
readonly_fields = ("request_payload", "response_payload")

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ai"

View File

@ -0,0 +1,73 @@
VOLCANO_PROVIDER = {
"name": "volcengine",
"display_name": "火山引擎(豆包)",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
}
VOLCANO_MODELS = [
{
"display_name": "Doubao-Seed-2.0-Pro",
"name": "doubao-seed-2-0-pro-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Doubao-Seed-2.0-Lite",
"name": "doubao-seed-2-0-lite-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Seedream-5.0",
"name": "doubao-seedream-5-0-260128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedream-4.5",
"name": "doubao-seedream-4-5-251128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-2.0",
"name": "doubao-seedance-2-0-260128",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
"durations": list(range(4, 16)),
"resolutions": ["480p", "720p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-1.5-Pro",
"name": "doubao-seedance-1-5-pro-251215",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional"],
"durations": list(range(4, 13)),
"resolutions": ["480p", "720p", "1080p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
]

View File

@ -0,0 +1,171 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ModelConfig",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
("display_name", models.CharField(max_length=128)),
(
"capability",
models.CharField(
choices=[
("text", "Text"),
("image", "Image"),
("video", "Video"),
("vision", "Vision"),
("export", "Export"),
],
max_length=32,
),
),
("endpoint", models.CharField(blank=True, max_length=255)),
(
"unit_price",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("rate_limit_per_minute", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
],
),
migrations.CreateModel(
name="ModelProvider",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64, unique=True)),
("display_name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("base_url", models.URLField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AITask",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"task_type",
models.CharField(
choices=[
("script_generation", "Script Generation"),
("script_optimization", "Script Optimization"),
("product_image", "Product Image"),
("person_image", "Person Image"),
("scene_image", "Scene Image"),
("storyboard", "Storyboard"),
("video_segment", "Video Segment"),
("export", "Export"),
],
max_length=48,
),
),
(
"status",
models.CharField(
choices=[
("created", "Created"),
("reserved", "Reserved"),
("submitted", "Submitted"),
("polling", "Polling"),
("postprocessing", "Postprocessing"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("compensating", "Compensating"),
("cancelled", "Cancelled"),
],
default="created",
max_length=32,
),
),
("idempotency_key", models.CharField(max_length=128, unique=True)),
("provider_task_id", models.CharField(blank=True, max_length=255)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
(
"estimated_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"actual_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
("error_code", models.CharField(blank=True, max_length=64)),
("error_message", models.TextField(blank=True)),
("submitted_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,78 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="aitask",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_tasks",
to="projects.project",
),
),
migrations.AddField(
model_name="aitask",
name="team",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
migrations.AddField(
model_name="aitask",
name="model_config",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="tasks",
to="ai.modelconfig",
),
),
migrations.AddField(
model_name="modelconfig",
name="provider",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="models",
to="ai.modelprovider",
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["team", "status"], name="ai_aitask_team_id_710ece_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["project", "task_type"], name="ai_aitask_project_f2850d_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["provider_task_id"], name="ai_aitask_provide_67beef_idx"
),
),
migrations.AlterUniqueTogether(
name="modelconfig",
unique_together={("provider", "name", "capability")},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,102 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class ModelProvider(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=64, unique=True)
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
return self.display_name
class ModelConfig(TimeStampedModel):
class Capability(models.TextChoices):
TEXT = "text", "Text"
IMAGE = "image", "Image"
VIDEO = "video", "Video"
VISION = "vision", "Vision"
EXPORT = "export", "Export"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
name = models.CharField(max_length=128)
display_name = models.CharField(max_length=128)
capability = models.CharField(max_length=32, choices=Capability.choices)
endpoint = models.CharField(max_length=255, blank=True)
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
rate_limit_per_minute = models.PositiveIntegerField(default=60)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("provider", "name", "capability")]
def __str__(self) -> str:
return f"{self.provider.name}:{self.name}:{self.capability}"
class AITask(TeamOwnedModel):
class Type(models.TextChoices):
SCRIPT_GENERATION = "script_generation", "Script Generation"
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
PRODUCT_IMAGE = "product_image", "Product Image"
PERSON_IMAGE = "person_image", "Person Image"
SCENE_IMAGE = "scene_image", "Scene Image"
STORYBOARD = "storyboard", "Storyboard"
VIDEO_SEGMENT = "video_segment", "Video Segment"
EXPORT = "export", "Export"
class Status(models.TextChoices):
CREATED = "created", "Created"
RESERVED = "reserved", "Reserved"
SUBMITTED = "submitted", "Submitted"
POLLING = "polling", "Polling"
POSTPROCESSING = "postprocessing", "Postprocessing"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
COMPENSATING = "compensating", "Compensating"
CANCELLED = "cancelled", "Cancelled"
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="ai_tasks",
)
task_type = models.CharField(max_length=48, choices=Type.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
idempotency_key = models.CharField(max_length=128, unique=True)
provider_task_id = models.CharField(max_length=255, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
error_code = models.CharField(max_length=64, blank=True)
error_message = models.TextField(blank=True)
submitted_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["project", "task_type"]),
models.Index(fields=["provider_task_id"]),
]
def __str__(self) -> str:
return f"{self.task_type}:{self.status}:{self.id}"

View File

@ -0,0 +1,6 @@
from .base import AIProvider, AIProviderResult
from .volcano import VolcanoArkProvider
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]

View File

@ -0,0 +1,19 @@
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass(frozen=True)
class AIProviderResult:
provider_task_id: str = ""
status: str = "succeeded"
payload: dict[str, Any] = field(default_factory=dict)
asset_urls: list[str] = field(default_factory=list)
class AIProvider(Protocol):
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
...
def poll(self, provider_task_id: str) -> AIProviderResult:
...

View File

@ -0,0 +1,173 @@
from dataclasses import dataclass
import base64
from io import BytesIO
from typing import Any
import requests
from django.conf import settings
from .base import AIProviderResult
@dataclass
class VolcanoArkProvider:
api_key: str | None = None
base_url: str | None = None
def __post_init__(self) -> None:
self.api_key = self.api_key or settings.VOLCANO.get("ark_api_key")
self.base_url = self.base_url or settings.VOLCANO.get("ark_base_url")
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
# The exact endpoint is resolved by ModelConfig; this adapter keeps IO centralized.
endpoint = payload.get("endpoint")
if not endpoint:
raise ValueError("Volcano request payload requires endpoint")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=payload.get("body", {}),
timeout=60,
)
response.raise_for_status()
data = response.json()
return AIProviderResult(
provider_task_id=str(data.get("id") or data.get("task_id") or ""),
status=str(data.get("status") or "submitted"),
payload=data,
)
def chat_completion(self, *, model: str, messages: list[dict[str, str]], endpoint: str = "chat/completions") -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": messages},
timeout=120,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_text(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
if choices:
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
output = data.get("output")
if isinstance(output, str):
return output
raise ValueError("Volcano response does not contain text content")
def poll(self, provider_task_id: str) -> AIProviderResult:
if not provider_task_id:
raise ValueError("provider_task_id is required")
return AIProviderResult(provider_task_id=provider_task_id, status="polling", payload={})
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "2K",
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"response_format": "url",
"watermark": False,
"size": size,
"sequential_image_generation": "disabled",
}
if image:
body["image"] = image
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=180,
)
response.raise_for_status()
return response.json()
def create_video_task(
self,
*,
model: str,
endpoint: str,
prompt: str,
ratio: str = "9:16",
duration: int = 15,
resolution: str = "720p",
reference_images: list[str] | None = None,
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for image_url in reference_images or []:
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
body = {
"model": model,
"content": content,
"ratio": ratio,
"duration": duration,
"resolution": resolution,
"watermark": False,
"generate_audio": False,
}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=120,
)
response.raise_for_status()
return response.json()
def poll_video_task(self, *, endpoint: str, provider_task_id: str) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.get(
f"{self.base_url.rstrip('/')}/{endpoint.rstrip('/')}/{provider_task_id}",
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_first_media_url(data: dict[str, Any]) -> str:
items = data.get("data") or []
for item in items:
if item.get("url"):
return item["url"]
if item.get("b64_json"):
return item["b64_json"]
content = data.get("content") or {}
if content.get("video_url"):
return content["video_url"]
raise ValueError("Volcano response does not contain media url")
@staticmethod
def media_to_bytes(media: str) -> tuple[BytesIO, str]:
if media.startswith("http://") or media.startswith("https://"):
response = requests.get(media, timeout=180)
response.raise_for_status()
return BytesIO(response.content), response.headers.get("content-type", "application/octet-stream")
if "," in media and media.startswith("data:"):
header, raw = media.split(",", 1)
content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream"
return BytesIO(base64.b64decode(raw)), content_type
return BytesIO(base64.b64decode(media)), "image/png"

View File

@ -0,0 +1,44 @@
from rest_framework import serializers
from .models import AITask, ModelConfig, ModelProvider
class ModelProviderSerializer(serializers.ModelSerializer):
class Meta:
model = ModelProvider
fields = ["id", "name", "display_name", "status", "base_url", "metadata"]
read_only_fields = fields
class ModelConfigSerializer(serializers.ModelSerializer):
provider = ModelProviderSerializer(read_only=True)
class Meta:
model = ModelConfig
fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "metadata"]
read_only_fields = fields
class AITaskSerializer(serializers.ModelSerializer):
model_config = ModelConfigSerializer(read_only=True)
class Meta:
model = AITask
fields = [
"id",
"project",
"task_type",
"status",
"model_config",
"provider_task_id",
"estimated_cost",
"actual_cost",
"error_code",
"error_message",
"submitted_at",
"completed_at",
"created_at",
"updated_at",
]
read_only_fields = fields

View File

@ -0,0 +1,421 @@
import uuid
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import VolcanoArkProvider
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from apps.projects.models import (
BaseAssetGroup,
ExportJob,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
VideoSegment,
VideoSegmentVersion,
)
def get_default_model(capability: str) -> ModelConfig:
return (
ModelConfig.objects.select_related("provider")
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
.order_by("created_at")
.first()
)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
)
user = f"""
商品标题{product.title}
品牌{product.brand or "未填写"}
类目{product.category or "未填写"}
目标人群{product.target_audience or "未填写"}
商品描述{product.description or "未填写"}
卖点
{selling_text or "未选择卖点,请根据商品信息自行提炼。"}
用户补充需求
{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"}
""".strip()
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def split_script_into_segments(content: str) -> list[str]:
blocks = [line.strip() for line in content.splitlines() if line.strip()]
if len(blocks) >= 4:
return blocks[:4]
if not content.strip():
return [""] * 4
return [content.strip()] + [""] * (4 - len(blocks or [content]))
@transaction.atomic
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
cost = estimate_cost(model_config)
task = AITask.objects.create(
team=project.team,
created_by=user,
project=project,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
)
reserve_credit(team=project.team, user=user, task=task, amount=cost)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids)
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
script = ScriptVersion.objects.create(
project=project,
task=task,
title="AI 脚本",
content=content,
source="ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=segment_text,
visual_prompt=segment_text,
)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
except Exception as exc:
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
if "video" in content_type:
suffix = ".mp4"
elif "jpeg" in content_type:
suffix = ".jpg"
elif "webp" in content_type:
suffix = ".webp"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=user,
name=name,
asset_type=asset_type,
source=Asset.Source.AI_GENERATED,
category=category,
origin_task=task,
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return asset
def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGroup:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "kind": kind}
task = create_ai_task(
project=project,
user=user,
task_type={
BaseAssetGroup.Kind.PRODUCT: AITask.Type.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: AITask.Type.PERSON_IMAGE,
BaseAssetGroup.Kind.SCENE: AITask.Type.SCENE_IMAGE,
}[kind],
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
category = {
BaseAssetGroup.Kind.PRODUCT: Asset.Category.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: Asset.Category.PERSON,
BaseAssetGroup.Kind.SCENE: Asset.Category.SCENE,
}[kind]
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-{kind}",
category=category,
asset_type=Asset.Type.IMAGE,
)
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt)
group.candidate_assets.add(asset)
group.adopted_asset = asset
group.save(update_fields=["adopted_asset", "updated_at"])
return group
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def generate_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard")
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
storyboard = StoryboardVersion.objects.create(project=project, prompt=prompt)
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
for segment in adopted_script.segments.all():
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": segment.visual_prompt},
)
reservation = task.credit_reservation
try:
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=f"{prompt}\n{segment.visual_prompt}".strip(),
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.SCENE,
asset_type=Asset.Type.IMAGE,
)
StoryboardFrame.objects.create(
storyboard=storyboard,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
storyboard.is_adopted = True
storyboard.save(update_fields=["is_adopted", "updated_at"])
return storyboard
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"video_segment_id": str(video_segment.id),
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
)
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
task.response_payload = response
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
video_segment.status = VideoSegment.Status.RUNNING
video_segment.save(update_fields=["status", "updated_at"])
return None
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=task.credit_reservation, reason=str(exc))
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = str(exc)
video_segment.save(update_fields=["status", "error_message", "updated_at"])
raise
def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None:
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
ai_task = task.task
if ai_task is None:
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
if ai_task is None:
raise ValueError("no active video generation task")
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
ai_task.status = AITask.Status.POLLING
ai_task.response_payload = response
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
return None
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
ai_task.response_payload = response
ai_task.error_message = response.get("error", {}).get("message", "video generation failed")
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message)
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = ai_task.error_message
video_segment.save(update_fields=["status", "error_message", "updated_at"])
return None
media = provider.extract_first_media_url(response)
asset = _store_generated_media(
team=video_segment.project.team,
user=user,
project=video_segment.project,
task=ai_task,
media=media,
name=f"{video_segment.project.name}-segment-{video_segment.sort_order + 1}",
category=Asset.Category.VIDEO_CLIP,
asset_type=Asset.Type.VIDEO,
)
ai_task.status = AITask.Status.SUCCEEDED
ai_task.response_payload = response
ai_task.actual_cost = ai_task.estimated_cost
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=ai_task,
asset=asset,
prompt=ai_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return version
def create_export_job(*, timeline, user) -> ExportJob:
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)

View File

@ -0,0 +1,12 @@
from airshelf.celery import app
@app.task(bind=True, max_retries=3)
def submit_ai_task(self, task_id: str) -> str:
return task_id
@app.task(bind=True, max_retries=5)
def poll_ai_task(self, task_id: str) -> str:
return task_id

View File

@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import AITaskViewSet, ModelConfigViewSet
router = DefaultRouter()
router.register("tasks", AITaskViewSet, basename="ai-task")
router.register("models", ModelConfigViewSet, basename="model-config")
urlpatterns = router.urls

View File

@ -0,0 +1,21 @@
from rest_framework.viewsets import ReadOnlyModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").all()
serializer_class = AITaskSerializer
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
ordering_fields = ["created_at", "updated_at", "completed_at"]
class ModelConfigViewSet(ReadOnlyModelViewSet):
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE)
serializer_class = ModelConfigSerializer
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Asset, AssetFile, AssetTag, AssetTagging, AssetUsage
class AssetFileInline(admin.TabularInline):
model = AssetFile
extra = 0
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
list_display = ("name", "team", "asset_type", "source", "category", "is_deleted", "created_at")
search_fields = ("name", "team__name")
list_filter = ("asset_type", "source", "category", "is_deleted")
inlines = [AssetFileInline]
admin.site.register(AssetTag)
admin.site.register(AssetTagging)
admin.site.register(AssetUsage)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.assets"

View File

@ -0,0 +1,232 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Asset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"asset_type",
models.CharField(
choices=[
("image", "Image"),
("video", "Video"),
("audio", "Audio"),
("subtitle", "Subtitle"),
("document", "Document"),
],
max_length=24,
),
),
(
"source",
models.CharField(
choices=[
("upload", "Upload"),
("ai_generated", "AI Generated"),
("exported", "Exported"),
("system", "System"),
],
max_length=32,
),
),
(
"category",
models.CharField(
choices=[
("person", "Person"),
("scene", "Scene"),
("product_image", "Product Image"),
("video_clip", "Video Clip"),
("final_video", "Final Video"),
("upload", "Upload"),
("uncategorized", "Uncategorized"),
],
default="uncategorized",
max_length=32,
),
),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"origin_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="generated_assets",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AssetFile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_key", models.CharField(max_length=512)),
("bucket", models.CharField(max_length=128)),
("content_type", models.CharField(blank=True, max_length=128)),
("size_bytes", models.BigIntegerField(default=0)),
("checksum", models.CharField(blank=True, max_length=128)),
("width", models.PositiveIntegerField(blank=True, null=True)),
("height", models.PositiveIntegerField(blank=True, null=True)),
("duration_ms", models.PositiveIntegerField(blank=True, null=True)),
("preview_url", models.URLField(blank=True)),
("is_primary", models.BooleanField(default=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="assets.asset",
),
),
],
),
migrations.CreateModel(
name="AssetTag",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64)),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_tags",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="AssetTagging",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.asset",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.assettag",
),
),
],
),
migrations.CreateModel(
name="AssetUsage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("usage_type", models.CharField(max_length=64)),
("context", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="usages",
to="assets.asset",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("assets", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="assetusage",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_usages",
to="projects.project",
),
),
migrations.AddIndex(
model_name="assetfile",
index=models.Index(
fields=["bucket", "object_key"], name="assets_asse_bucket_94a505_idx"
),
),
migrations.AlterUniqueTogether(
name="assettag",
unique_together={("team", "name")},
),
migrations.AlterUniqueTogether(
name="assettagging",
unique_together={("asset", "tag")},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,100 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Asset(TeamOwnedModel):
class Type(models.TextChoices):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
AUDIO = "audio", "Audio"
SUBTITLE = "subtitle", "Subtitle"
DOCUMENT = "document", "Document"
class Source(models.TextChoices):
UPLOAD = "upload", "Upload"
AI_GENERATED = "ai_generated", "AI Generated"
EXPORTED = "exported", "Exported"
SYSTEM = "system", "System"
class Category(models.TextChoices):
PERSON = "person", "Person"
SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized"
name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices)
source = models.CharField(max_length=32, choices=Source.choices)
category = models.CharField(max_length=32, choices=Category.choices, default=Category.UNCATEGORIZED)
description = models.TextField(blank=True)
origin_task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_assets",
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name
class AssetFile(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files")
object_key = models.CharField(max_length=512)
bucket = models.CharField(max_length=128)
content_type = models.CharField(max_length=128, blank=True)
size_bytes = models.BigIntegerField(default=0)
checksum = models.CharField(max_length=128, blank=True)
width = models.PositiveIntegerField(null=True, blank=True)
height = models.PositiveIntegerField(null=True, blank=True)
duration_ms = models.PositiveIntegerField(null=True, blank=True)
preview_url = models.URLField(blank=True)
is_primary = models.BooleanField(default=True)
class Meta:
indexes = [
models.Index(fields=["bucket", "object_key"]),
]
def __str__(self) -> str:
return self.object_key
class AssetTag(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="asset_tags")
name = models.CharField(max_length=64)
class Meta:
unique_together = [("team", "name")]
def __str__(self) -> str:
return self.name
class AssetTagging(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="taggings")
tag = models.ForeignKey(AssetTag, on_delete=models.CASCADE, related_name="taggings")
class Meta:
unique_together = [("asset", "tag")]
class AssetUsage(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="usages")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="asset_usages",
)
usage_type = models.CharField(max_length=64)
context = models.JSONField(default=dict, blank=True)

View File

@ -0,0 +1,51 @@
from rest_framework import serializers
from .models import Asset, AssetFile
class AssetFileSerializer(serializers.ModelSerializer):
class Meta:
model = AssetFile
fields = [
"id",
"object_key",
"bucket",
"content_type",
"size_bytes",
"width",
"height",
"duration_ms",
"preview_url",
"is_primary",
]
read_only_fields = fields
class AssetSerializer(serializers.ModelSerializer):
files = AssetFileSerializer(many=True, read_only=True)
class Meta:
model = Asset
fields = [
"id",
"name",
"asset_type",
"source",
"category",
"description",
"metadata",
"is_deleted",
"files",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class AssetUploadSerializer(serializers.Serializer):
file = serializers.FileField()
name = serializers.CharField(max_length=255, required=False, allow_blank=True)
asset_type = serializers.ChoiceField(choices=Asset.Type.choices)
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True)

View File

@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import BinaryIO
import boto3
from botocore.config import Config
from django.conf import settings
@dataclass(frozen=True)
class StoredObject:
bucket: str
object_key: str
content_type: str
size_bytes: int
class TosStorage:
def __init__(self) -> None:
tos = settings.TOS
self.bucket = tos["bucket"]
self.client = boto3.client(
"s3",
endpoint_url=tos["endpoint"],
aws_access_key_id=tos["access_key_id"],
aws_secret_access_key=tos["secret_access_key"],
region_name="cn-shanghai",
config=Config(s3={"addressing_style": "virtual"}),
)
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
fileobj.seek(0, 2)
size = fileobj.tell()
fileobj.seek(0)
self.client.upload_fileobj(
fileobj,
self.bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
return StoredObject(
bucket=self.bucket,
object_key=object_key,
content_type=content_type,
size_bytes=size,
)
def presigned_get_url(self, *, object_key: str, expires_in: int = 3600) -> str:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=expires_in,
)

View File

@ -0,0 +1,11 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AssetUploadView, AssetViewSet
router = DefaultRouter()
router.register("", AssetViewSet, basename="asset")
urlpatterns = [
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
] + router.urls

View File

@ -0,0 +1,61 @@
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile
from .serializers import AssetSerializer, AssetUploadSerializer
from .storage import TosStorage
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Asset.objects.prefetch_related("files").all()
serializer_class = AssetSerializer
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@transaction.atomic
def post(self, request):
serializer = AssetUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
team = get_current_team(request.user)
upload = serializer.validated_data["file"]
suffix = Path(upload.name).suffix.lower()
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=serializer.validated_data.get("name") or upload.name,
asset_type=serializer.validated_data["asset_type"],
source=Asset.Source.UPLOAD,
category=serializer.validated_data["category"],
description=serializer.validated_data.get("description", ""),
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,21 @@
from django.contrib import admin
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
@admin.register(CreditAccount)
class CreditAccountAdmin(admin.ModelAdmin):
list_display = ("team", "balance", "reserved_balance", "currency", "updated_at")
search_fields = ("team__name",)
@admin.register(CreditLedger)
class CreditLedgerAdmin(admin.ModelAdmin):
list_display = ("team", "user", "project", "task", "ledger_type", "amount", "balance_after", "created_at")
search_fields = ("team__name", "user__username", "project__name", "task__idempotency_key")
list_filter = ("ledger_type",)
admin.site.register(CreditReservation)
admin.site.register(QuotaPolicy)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"

View File

@ -0,0 +1,277 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0002_initial"),
("projects", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CreditAccount",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
(
"reserved_balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
("currency", models.CharField(default="CNY", max_length=16)),
(
"team",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_account",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditReservation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("released", "Released"),
("charged", "Charged"),
("cancelled", "Cancelled"),
],
default="active",
max_length=32,
),
),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to="projects.project",
),
),
(
"task",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservation",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservations",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="QuotaPolicy",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"monthly_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"project_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"per_task_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
("is_active", models.BooleanField(default=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="projects.project",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditLedger",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"ledger_type",
models.CharField(
choices=[
("recharge", "Recharge"),
("reserve", "Reserve"),
("release", "Release"),
("charge", "Charge"),
("adjustment", "Adjustment"),
("refund", "Refund"),
],
max_length=32,
),
),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
("balance_after", models.DecimalField(decimal_places=4, max_digits=14)),
("reason", models.CharField(blank=True, max_length=255)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_ledgers",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["team", "ledger_type"],
name="billing_cre_team_id_e0f18f_idx",
),
models.Index(
fields=["project", "task"],
name="billing_cre_project_a79834_idx",
),
],
},
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,84 @@
from django.db import models
from apps.common.models import TimeStampedModel
class CreditAccount(TimeStampedModel):
team = models.OneToOneField("accounts.Team", on_delete=models.CASCADE, related_name="credit_account")
balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
reserved_balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
currency = models.CharField(max_length=16, default="CNY")
def __str__(self) -> str:
return f"{self.team} / {self.balance}"
class CreditLedger(TimeStampedModel):
class Type(models.TextChoices):
RECHARGE = "recharge", "Recharge"
RESERVE = "reserve", "Reserve"
RELEASE = "release", "Release"
CHARGE = "charge", "Charge"
ADJUSTMENT = "adjustment", "Adjustment"
REFUND = "refund", "Refund"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_ledgers")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_ledgers",
)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
ledger_type = models.CharField(max_length=32, choices=Type.choices)
amount = models.DecimalField(max_digits=14, decimal_places=4)
balance_after = models.DecimalField(max_digits=14, decimal_places=4)
reason = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "ledger_type"]),
models.Index(fields=["project", "task"]),
]
class CreditReservation(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
RELEASED = "released", "Released"
CHARGED = "charged", "Charged"
CANCELLED = "cancelled", "Cancelled"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_reservations")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_reservations")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_reservations",
)
task = models.OneToOneField("ai.AITask", on_delete=models.CASCADE, related_name="credit_reservation")
amount = models.DecimalField(max_digits=14, decimal_places=4)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
expires_at = models.DateTimeField(null=True, blank=True)
class QuotaPolicy(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies")
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, null=True, blank=True, related_name="quota_policies")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="quota_policies",
)
monthly_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
project_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
per_task_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
is_active = models.BooleanField(default=True)

View File

@ -0,0 +1,43 @@
from rest_framework import serializers
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
class CreditAccountSerializer(serializers.ModelSerializer):
class Meta:
model = CreditAccount
fields = ["id", "balance", "reserved_balance", "currency", "updated_at"]
read_only_fields = fields
class CreditLedgerSerializer(serializers.ModelSerializer):
class Meta:
model = CreditLedger
fields = [
"id",
"user",
"project",
"task",
"ledger_type",
"amount",
"balance_after",
"reason",
"metadata",
"created_at",
]
read_only_fields = fields
class CreditReservationSerializer(serializers.ModelSerializer):
class Meta:
model = CreditReservation
fields = ["id", "user", "project", "task", "amount", "status", "expires_at", "created_at"]
read_only_fields = fields
class QuotaPolicySerializer(serializers.ModelSerializer):
class Meta:
model = QuotaPolicy
fields = ["id", "user", "project", "monthly_limit", "project_limit", "per_task_limit", "is_active"]
read_only_fields = ["id"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,93 @@
from decimal import Decimal
from django.db import transaction
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
@transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
available = account.balance - account.reserved_balance
if available < amount:
raise ValueError("insufficient credit")
account.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation = CreditReservation.objects.create(
team=team,
user=user,
project=task.project,
task=task,
amount=amount,
)
CreditLedger.objects.create(
team=team,
user=user,
project=task.project,
task=task,
ledger_type=CreditLedger.Type.RESERVE,
amount=amount,
balance_after=account.balance,
reason="reserve ai task credit",
)
return reservation
@transaction.atomic
def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
return
account.reserved_balance -= reservation.amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.RELEASED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount,
balance_after=account.balance,
reason=reason or "release reserved credit",
)
@transaction.atomic
def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
raise ValueError("reservation is not active")
if actual_amount > reservation.amount:
raise ValueError("actual amount exceeds reserved amount")
account.balance -= actual_amount
account.reserved_balance -= reservation.amount
account.save(update_fields=["balance", "reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.CHARGED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.CHARGE,
amount=actual_amount,
balance_after=account.balance,
reason="charge ai task credit",
)
if reservation.amount > actual_amount:
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount - actual_amount,
balance_after=account.balance,
reason="release unused reserved credit",
)

View File

@ -0,0 +1,60 @@
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
class CreditLedgerTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="Billing Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
self.model = ModelConfig.objects.create(
provider=self.provider,
name="doubao-seed-2-0-pro-260215",
display_name="Doubao",
capability=ModelConfig.Capability.TEXT,
)
self.task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=self.model,
idempotency_key="billing-test-task",
estimated_cost=Decimal("10.0000"),
)
def test_reserve_and_charge_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
self.account.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.ACTIVE)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("10.0000"))
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("8.0000"))
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
self.assertEqual(self.account.balance, Decimal("92.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 3)
def test_release_reserved_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
release_credit(reservation=reservation, reason="model failed")
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)

View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import ledgers, recharge, summary
urlpatterns = [
path("summary/", summary, name="billing-summary"),
path("ledgers/", ledgers, name="billing-ledgers"),
path("recharge/", recharge, name="billing-recharge"),
]

View File

@ -0,0 +1,80 @@
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def summary(request):
team = get_current_team(request.user)
account, _ = CreditAccount.objects.get_or_create(team=team)
charged = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE).aggregate(
total=Sum("amount")
)["total"] or 0
return Response(
{
"account": CreditAccountSerializer(account).data,
"charged_total": charged,
}
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def ledgers(request):
team = get_current_team(request.user)
queryset = CreditLedger.objects.filter(team=team).select_related("user", "project", "task").order_by("-created_at")
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
if project_id:
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def recharge(request):
team = get_current_team(request.user)
try:
amount = Decimal(str(request.data.get("amount", "0")))
bonus = Decimal(str(request.data.get("bonus", "0")))
except (InvalidOperation, TypeError):
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
if amount <= 0:
return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST)
if bonus < 0:
return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST)
channel = str(request.data.get("channel") or "manual")[:32]
credited = amount + bonus
with transaction.atomic():
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
account.balance += credited
account.save(update_fields=["balance", "updated_at"])
ledger = CreditLedger.objects.create(
team=team,
user=request.user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=credited,
balance_after=account.balance,
reason="团队充值",
metadata={"channel": channel, "paid_amount": str(amount), "bonus": str(bonus)},
)
return Response(
{
"account": CreditAccountSerializer(account).data,
"ledger": CreditLedgerSerializer(ledger).data,
},
status=status.HTTP_201_CREATED,
)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,23 @@
from rest_framework.exceptions import PermissionDenied
def get_current_team(user):
membership = user.team_memberships.filter(status="active").select_related("team").first()
if not membership:
raise PermissionDenied("current user has no active team")
return membership.team
class TeamScopedViewSetMixin:
team_field = "team"
def get_team(self):
return get_current_team(self.request.user)
def get_queryset(self):
queryset = super().get_queryset()
return queryset.filter(**{self.team_field: self.get_team()})
def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from apps.ai.catalog import VOLCANO_MODELS, VOLCANO_PROVIDER
from apps.ai.models import ModelConfig, ModelProvider
class Command(BaseCommand):
help = "Create or update default Volcano model provider and model configs."
def handle(self, *args, **options):
provider, _ = ModelProvider.objects.update_or_create(
name=VOLCANO_PROVIDER["name"],
defaults={
"display_name": VOLCANO_PROVIDER["display_name"],
"base_url": VOLCANO_PROVIDER["base_url"],
"status": ModelProvider.Status.ACTIVE,
},
)
count = 0
for item in VOLCANO_MODELS:
ModelConfig.objects.update_or_create(
provider=provider,
name=item["name"],
capability=item["capability"],
defaults={
"display_name": item["display_name"],
"endpoint": item["endpoint"],
"status": ModelConfig.Status.ACTIVE,
"metadata": item["metadata"],
},
)
count += 1
self.stdout.write(self.style.SUCCESS(f"Bootstrapped {count} Volcano model configs."))

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,33 @@
import uuid
from django.db import models
class UUIDModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
abstract = True
class TimeStampedModel(UUIDModel):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class TeamOwnedModel(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="%(class)s_set")
created_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_%(class)s_set",
)
class Meta:
abstract = True

View File

@ -0,0 +1,6 @@
from django.http import JsonResponse
def health_check(request):
return JsonResponse({"status": "ok", "service": "airshelf-backend"})

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,16 @@
from django.contrib import admin
from .models import Notification
admin.site.site_header = "AirShelf Ops"
admin.site.site_title = "AirShelf Ops"
admin.site.index_title = "Operations"
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ("title", "team", "recipient", "notification_type", "priority", "is_read", "created_at")
list_filter = ("notification_type", "priority", "is_read", "archived_at")
search_fields = ("title", "brief", "body", "source", "dedupe_key")
readonly_fields = ("created_at", "updated_at", "read_at", "archived_at")

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class OpsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ops"

View File

@ -0,0 +1,105 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("accounts", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[("task", "Task"), ("team", "Team"), ("billing", "Billing"), ("system", "System")],
default="system",
max_length=24,
),
),
(
"priority",
models.CharField(
choices=[("ok", "OK"), ("warn", "Warn"), ("err", "Error"), ("info", "Info")],
default="info",
max_length=24,
),
),
("title", models.CharField(max_length=200)),
("brief", models.CharField(blank=True, max_length=300)),
("body", models.TextField(blank=True)),
("source", models.CharField(blank=True, max_length=120)),
("stage", models.CharField(blank=True, max_length=120)),
("owner_label", models.CharField(blank=True, max_length=120)),
("cost_label", models.CharField(blank=True, max_length=64)),
("related_url", models.CharField(blank=True, max_length=300)),
("dedupe_key", models.CharField(blank=True, max_length=160)),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("archived_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notifications",
to="projects.project",
),
),
(
"recipient",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="accounts.team",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "recipient", "is_read", "-created_at"], name="ops_notific_team_id_17a7ca_idx"),
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "archived_at", "-created_at"], name="ops_notific_team_id_691eaf_idx"),
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "dedupe_key"], name="ops_notific_team_id_8acdf4_idx"),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
condition=~models.Q(dedupe_key=""),
fields=("team", "dedupe_key"),
name="ops_notification_team_dedupe_key_unique",
),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.1.15 on 2026-06-01 09:15
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("ops", "0001_initial"),
("projects", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="notification",
name="ops_notification_team_dedupe_key_unique",
),
migrations.AlterField(
model_name="notification",
name="dedupe_key",
field=models.CharField(blank=True, max_length=160, null=True),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
fields=("team", "dedupe_key"),
name="ops_notification_team_dedupe_key_unique",
),
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,84 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.common.models import TimeStampedModel
class Notification(TimeStampedModel):
class Type(models.TextChoices):
TASK = "task", "Task"
TEAM = "team", "Team"
BILLING = "billing", "Billing"
SYSTEM = "system", "System"
class Priority(models.TextChoices):
OK = "ok", "OK"
WARN = "warn", "Warn"
ERR = "err", "Error"
INFO = "info", "Info"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="notifications")
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="notifications",
)
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="notifications",
)
notification_type = models.CharField(max_length=24, choices=Type.choices, default=Type.SYSTEM)
priority = models.CharField(max_length=24, choices=Priority.choices, default=Priority.INFO)
title = models.CharField(max_length=200)
brief = models.CharField(max_length=300, blank=True)
body = models.TextField(blank=True)
source = models.CharField(max_length=120, blank=True)
stage = models.CharField(max_length=120, blank=True)
owner_label = models.CharField(max_length=120, blank=True)
cost_label = models.CharField(max_length=64, blank=True)
related_url = models.CharField(max_length=300, blank=True)
dedupe_key = models.CharField(max_length=160, blank=True, null=True)
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["team", "recipient", "is_read", "-created_at"]),
models.Index(fields=["team", "archived_at", "-created_at"]),
models.Index(fields=["team", "dedupe_key"]),
]
constraints = [
models.UniqueConstraint(
fields=["team", "dedupe_key"],
name="ops_notification_team_dedupe_key_unique",
)
]
def mark_read(self):
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at", "updated_at"])
def mark_unread(self):
if self.is_read or self.read_at:
self.is_read = False
self.read_at = None
self.save(update_fields=["is_read", "read_at", "updated_at"])
def archive(self):
if self.archived_at is None:
self.archived_at = timezone.now()
self.save(update_fields=["archived_at", "updated_at"])
def __str__(self) -> str:
return self.title

View File

@ -0,0 +1,47 @@
from rest_framework import serializers
from .models import Notification
class NotificationSerializer(serializers.ModelSerializer):
type = serializers.CharField(source="notification_type", read_only=True)
unread = serializers.SerializerMethodField()
project_name = serializers.CharField(source="project.name", read_only=True)
class Meta:
model = Notification
fields = [
"id",
"type",
"notification_type",
"priority",
"title",
"brief",
"body",
"source",
"project",
"project_name",
"stage",
"owner_label",
"cost_label",
"related_url",
"is_read",
"unread",
"read_at",
"archived_at",
"metadata",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"type",
"project_name",
"read_at",
"archived_at",
"created_at",
"updated_at",
]
def get_unread(self, obj):
return not obj.is_read

View File

@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import NotificationViewSet
router = DefaultRouter()
router.register("notifications", NotificationViewSet, basename="notification")
urlpatterns = router.urls

View File

@ -0,0 +1,167 @@
from django.db.models import Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.assets.models import Asset
from apps.billing.models import CreditAccount
from apps.common.api import TeamScopedViewSetMixin
from apps.projects.models import Project
from .models import Notification
from .serializers import NotificationSerializer
def project_stage_label(project):
return {
"script": "Stage 1 · 脚本",
"base_assets": "Stage 2 · 基础资产",
"storyboard": "Stage 3 · 故事板",
"video": "Stage 4 · 视频",
"export": "Stage 5 · 导出",
}.get(project.current_stage, "Stage 1 · 脚本")
def project_priority(project):
if project.status == Project.Status.COMPLETED:
return Notification.Priority.OK
if project.status == Project.Status.FAILED:
return Notification.Priority.ERR
return Notification.Priority.INFO
def ensure_team_notifications(team, user):
def create_once(dedupe_key, **payload):
Notification.objects.get_or_create(
team=team,
recipient=user,
dedupe_key=dedupe_key,
defaults=payload,
)
create_once(
"system:welcome",
notification_type=Notification.Type.SYSTEM,
priority=Notification.Priority.INFO,
title="团队已接入 AirShelf",
brief="真实消息中心已启用,状态会写入 Django 数据库。",
body="消息已从演示数据切换为团队级通知表。已读、未读、归档等操作都会持久化保存。",
source="Airshelf 系统",
stage="系统公告",
owner_label="系统",
cost_label="-",
related_url="settings.html#sec-notify",
)
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
product_title = project.product.title if project.product_id else "未绑定商品"
create_once(
f"project:{project.id}:status:{project.status}:{project.current_stage}",
notification_type=Notification.Type.TASK,
priority=project_priority(project),
title=f"项目「{project.name}」状态更新",
brief=f"{product_title} · {project_stage_label(project)} · {project.get_status_display()}",
body=f"项目「{project.name}」当前处于 {project_stage_label(project)}。这条消息来自 Django 项目表,刷新后状态会保持一致。",
source="视频项目",
project=project,
stage=project_stage_label(project),
owner_label=project.created_by.username if project.created_by_id else "成员",
cost_label="-",
related_url=f"pipeline.html?project_id={project.id}",
metadata={"status": project.status, "current_stage": project.current_stage},
)
for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]:
create_once(
f"asset:{asset.id}:created",
notification_type=Notification.Type.TASK,
priority=Notification.Priority.OK,
title=f"资产「{asset.name}」已加入资产库",
brief=f"{asset.get_category_display()} · {asset.get_asset_type_display()}",
body="资产记录来自真实资产表。后续上传、AI 生成、导出成片都可以在这里形成团队通知。",
source="资产库",
stage="资产入库",
owner_label=asset.created_by.username if asset.created_by_id else "成员",
cost_label="-",
related_url="library.html",
metadata={"asset_id": str(asset.id), "category": asset.category, "asset_type": asset.asset_type},
)
account, _ = CreditAccount.objects.get_or_create(team=team)
if account.balance <= 100:
create_once(
f"billing:low-balance:{account.id}",
notification_type=Notification.Type.BILLING,
priority=Notification.Priority.WARN,
title="团队余额低于预警线",
brief=f"当前余额 ¥{account.balance:.2f},建议及时充值。",
body="余额低于 100 元时系统会生成预警通知。充值或调低成员额度后可在消费页查看最新账本。",
source="计费中心",
stage="余额监控",
owner_label="系统",
cost_label=f"¥{account.balance:.2f}",
related_url="account.html",
)
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
serializer_class = NotificationSerializer
queryset = Notification.objects.select_related("team", "recipient", "project").all()
search_fields = ["title", "brief", "body", "source", "stage"]
ordering_fields = ["created_at", "updated_at"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset().filter(archived_at__isnull=True)
user = self.request.user
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
notification_type = self.request.query_params.get("type")
if notification_type and notification_type not in {"all", "unread"}:
queryset = queryset.filter(notification_type=notification_type)
if self.request.query_params.get("unread") in {"1", "true", "yes"}:
queryset = queryset.filter(is_read=False)
return queryset
def list(self, request, *args, **kwargs):
ensure_team_notifications(self.get_team(), request.user)
response = super().list(request, *args, **kwargs)
data = response.data
unread_count = self.get_queryset().filter(is_read=False).count()
if isinstance(data, dict):
data["unread_count"] = unread_count
return response
def perform_create(self, serializer):
serializer.save(team=self.get_team(), recipient=self.request.user)
@action(detail=False, methods=["post"], url_path="mark-all-read")
def mark_all_read(self, request):
now = timezone.now()
count = self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now, updated_at=now)
return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()})
@action(detail=False, methods=["post"], url_path="mark-all-unread")
def mark_all_unread(self, request):
now = timezone.now()
count = self.get_queryset().filter(is_read=True).update(is_read=False, read_at=None, updated_at=now)
return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()})
@action(detail=True, methods=["post"], url_path="mark-read")
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.mark_read()
return Response(self.get_serializer(notification).data)
@action(detail=True, methods=["post"], url_path="mark-unread")
def mark_unread(self, request, pk=None):
notification = self.get_object()
notification.mark_unread()
return Response(self.get_serializer(notification).data)
@action(detail=True, methods=["post"], url_path="archive")
def archive(self, request, pk=None):
notification = self.get_object()
notification.archive()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Product, ProductImage, ProductSellingPoint
class ProductImageInline(admin.TabularInline):
model = ProductImage
extra = 0
class ProductSellingPointInline(admin.TabularInline):
model = ProductSellingPoint
extra = 0
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "team", "brand", "category", "status", "updated_at")
search_fields = ("title", "brand", "category", "team__name")
list_filter = ("status", "category")
inlines = [ProductImageInline, ProductSellingPointInline]

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"

View File

@ -0,0 +1,157 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("assets", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=255)),
("brand", models.CharField(blank=True, max_length=128)),
("category", models.CharField(blank=True, max_length=128)),
("target_audience", models.CharField(blank=True, max_length=255)),
("specs", models.JSONField(blank=True, default=dict)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("archived", "Archived")],
default="active",
max_length=24,
),
),
(
"cover_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="covered_products",
to="assets.asset",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="ProductImage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("is_primary", models.BooleanField(default=False)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="product_images",
to="assets.asset",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="products.product",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="ProductSellingPoint",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=128)),
("detail", models.TextField(blank=True)),
("sort_order", models.PositiveIntegerField(default=0)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="selling_points",
to="products.product",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.AddIndex(
model_name="product",
index=models.Index(
fields=["team", "status"], name="products_pr_team_id_21af15_idx"
),
),
migrations.AddIndex(
model_name="product",
index=models.Index(
fields=["team", "category"], name="products_pr_team_id_1f3cfb_idx"
),
),
]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,54 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Product(TeamOwnedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
ARCHIVED = "archived", "Archived"
title = models.CharField(max_length=255)
brand = models.CharField(max_length=128, blank=True)
category = models.CharField(max_length=128, blank=True)
target_audience = models.CharField(max_length=255, blank=True)
specs = models.JSONField(default=dict, blank=True)
description = models.TextField(blank=True)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
cover_asset = models.ForeignKey(
"assets.Asset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="covered_products",
)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "category"]),
]
def __str__(self) -> str:
return self.title
class ProductImage(TimeStampedModel):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="images")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="product_images")
sort_order = models.PositiveIntegerField(default=0)
is_primary = models.BooleanField(default=False)
class Meta:
ordering = ["sort_order", "created_at"]
class ProductSellingPoint(TimeStampedModel):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="selling_points")
title = models.CharField(max_length=128)
detail = models.TextField(blank=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "created_at"]

View File

@ -0,0 +1,65 @@
from rest_framework import serializers
from .models import Product, ProductImage, ProductSellingPoint
class ProductImageSerializer(serializers.ModelSerializer):
class Meta:
model = ProductImage
fields = ["id", "asset", "sort_order", "is_primary"]
class ProductSellingPointSerializer(serializers.ModelSerializer):
class Meta:
model = ProductSellingPoint
fields = ["id", "title", "detail", "sort_order"]
class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, required=False)
selling_points = ProductSellingPointSerializer(many=True, required=False)
class Meta:
model = Product
fields = [
"id",
"title",
"brand",
"category",
"target_audience",
"specs",
"description",
"status",
"cover_asset",
"images",
"selling_points",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def create(self, validated_data):
images = validated_data.pop("images", [])
selling_points = validated_data.pop("selling_points", [])
product = Product.objects.create(**validated_data)
self._sync_children(product, images, selling_points)
return product
def update(self, instance, validated_data):
images = validated_data.pop("images", None)
selling_points = validated_data.pop("selling_points", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
self._sync_children(instance, images, selling_points)
return instance
def _sync_children(self, product, images, selling_points):
if images is not None:
product.images.all().delete()
for item in images:
ProductImage.objects.create(product=product, **item)
if selling_points is not None:
product.selling_points.all().delete()
for item in selling_points:
ProductSellingPoint.objects.create(product=product, **item)

View File

@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet
router = DefaultRouter()
router.register("", ProductViewSet, basename="product")
urlpatterns = router.urls

View File

@ -0,0 +1,14 @@
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from .models import Product
from .serializers import ProductSerializer
class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Product.objects.prefetch_related("images", "selling_points").all()
serializer_class = ProductSerializer
search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,46 @@
from django.contrib import admin
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectStageInline(admin.TabularInline):
model = ProjectStage
extra = 0
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "team", "product", "status", "current_stage", "updated_at")
search_fields = ("name", "team__name", "product__title")
list_filter = ("status", "current_stage")
inlines = [ProjectStageInline]
admin.site.register(ScriptVersion)
admin.site.register(ScriptSegment)
admin.site.register(BaseAssetGroup)
admin.site.register(StoryboardVersion)
admin.site.register(StoryboardFrame)
admin.site.register(VideoSegment)
admin.site.register(VideoSegmentVersion)
admin.site.register(Timeline)
admin.site.register(TimelineClip)
admin.site.register(SubtitleTrack)
admin.site.register(BgmTrack)
admin.site.register(ExportJob)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.projects"

View File

@ -0,0 +1,721 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("assets", "0001_initial"),
("products", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Project",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("scripting", "Scripting"),
("asseting", "Asseting"),
("storyboarding", "Storyboarding"),
("videoing", "Videoing"),
("exporting", "Exporting"),
("completed", "Completed"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("current_stage", models.CharField(default="script", max_length=32)),
(
"budget_limit",
models.DecimalField(
blank=True, decimal_places=2, max_digits=12, null=True
),
),
("failure_reason", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="projects",
to="products.product",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="BaseAssetGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"kind",
models.CharField(
choices=[
("product", "Product"),
("person", "Person"),
("scene", "Scene"),
],
max_length=32,
),
),
("prompt", models.TextField(blank=True)),
("version", models.PositiveIntegerField(default=1)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"adopted_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_base_groups",
to="assets.asset",
),
),
(
"candidate_assets",
models.ManyToManyField(
blank=True,
related_name="candidate_base_groups",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="base_asset_groups",
to="ai.aitask",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="base_asset_groups",
to="projects.project",
),
),
],
),
migrations.CreateModel(
name="ProjectStage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"stage",
models.CharField(
choices=[
("script", "Script"),
("base_assets", "Base Assets"),
("storyboard", "Storyboard"),
("video", "Video"),
("export", "Export"),
],
max_length=32,
),
),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("skipped", "Skipped"),
("needs_review", "Needs Review"),
],
default="not_started",
max_length=32,
),
),
("started_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stages",
to="projects.project",
),
),
],
options={
"ordering": ["created_at"],
},
),
migrations.CreateModel(
name="ScriptVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(blank=True, max_length=128)),
("content", models.TextField()),
("source", models.CharField(default="ai", max_length=32)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="script_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="script_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ScriptSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("duration_seconds", models.PositiveIntegerField(default=15)),
("narration", models.TextField(blank=True)),
("visual_prompt", models.TextField(blank=True)),
("product_points", models.JSONField(blank=True, default=list)),
(
"script_version",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="segments",
to="projects.scriptversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="StoryboardVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="storyboard_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="StoryboardFrame",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("prompt", models.TextField(blank=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="storyboard_frames",
to="assets.asset",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_frames",
to="projects.scriptsegment",
),
),
(
"storyboard",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="frames",
to="projects.storyboardversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="Timeline",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(blank=True, max_length=255)),
("aspect_ratio", models.CharField(default="9:16", max_length=16)),
("resolution", models.CharField(default="1080x1920", max_length=32)),
("duration_seconds", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline",
to="projects.project",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SubtitleTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("content", models.JSONField(blank=True, default=list)),
("style", models.JSONField(blank=True, default=dict)),
("enabled", models.BooleanField(default=True)),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="subtitle_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ExportJob",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("progress", models.PositiveIntegerField(default=0)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"output_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="ai.aitask",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="export_jobs",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="BgmTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("volume", models.PositiveIntegerField(default=60)),
("start_ms", models.PositiveIntegerField(default=0)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="bgm_tracks",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bgm_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TimelineClip",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("start_ms", models.PositiveIntegerField(default=0)),
("duration_ms", models.PositiveIntegerField(default=15000)),
("trim_start_ms", models.PositiveIntegerField(default=0)),
("trim_end_ms", models.PositiveIntegerField(blank=True, null=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="timeline_clips",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clips",
to="projects.timeline",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("target_duration_seconds", models.PositiveIntegerField(default=15)),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="not_started",
max_length=32,
),
),
("error_message", models.TextField(blank=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="video_segments",
to="projects.project",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_segments",
to="projects.scriptsegment",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegmentVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="video_segment_versions",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_versions",
to="ai.aitask",
),
),
(
"video_segment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="projects.videosegment",
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="videosegment",
name="adopted_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_by_segments",
to="projects.videosegmentversion",
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "status"], name="projects_pr_team_id_4a0091_idx"
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "current_stage"], name="projects_pr_team_id_a3c9ff_idx"
),
),
migrations.AddIndex(
model_name="baseassetgroup",
index=models.Index(
fields=["project", "kind"], name="projects_ba_project_8fb70a_idx"
),
),
migrations.AlterUniqueTogether(
name="projectstage",
unique_together={("project", "stage")},
),
migrations.AlterUniqueTogether(
name="videosegment",
unique_together={("project", "sort_order")},
),
]

View File

@ -0,0 +1 @@

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