feat: add AirShelf core implementation
This commit is contained in:
parent
2ba1058329
commit
cfdcd84a30
11
.gitignore
vendored
11
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
702
core/ARCHITECTURE.md
Normal 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_id;Worker 负责执行、轮询、重试、写状态。
|
||||
|
||||
### 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 0:Django cache
|
||||
- DB 1:Celery broker
|
||||
- DB 2:Celery 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 2:AI 纵向闭环
|
||||
|
||||
- 脚本生成
|
||||
- 基础资产生成
|
||||
- 故事板生成
|
||||
- 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 必须从第一天按真实产品设计。
|
||||
56
core/DESIGN_PARITY_AUDIT.md
Normal file
56
core/DESIGN_PARITY_AUDIT.md
Normal 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 多段生产、轮询、采用、导出和扣费回滚。
|
||||
|
||||
734
core/FEATURE_DEVELOPMENT_ALIGNMENT.md
Normal file
734
core/FEATURE_DEVELOPMENT_ALIGNMENT.md
Normal 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 + Celery,MySQL 持久化,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、MP4,60s 成片时长允许合理浮动。
|
||||
- 下载链接有过期时间,不暴露永久私有地址。
|
||||
- 删除测试数据后,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
26
core/backend/.env.example
Normal 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
44
core/backend/README.md
Normal 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
|
||||
```
|
||||
7
core/backend/airshelf/__init__.py
Normal file
7
core/backend/airshelf/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
try:
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
9
core/backend/airshelf/asgi.py
Normal file
9
core/backend/airshelf/asgi.py
Normal 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()
|
||||
|
||||
11
core/backend/airshelf/celery.py
Normal file
11
core/backend/airshelf/celery.py
Normal 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()
|
||||
|
||||
1
core/backend/airshelf/settings/__init__.py
Normal file
1
core/backend/airshelf/settings/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
175
core/backend/airshelf/settings/base.py
Normal file
175
core/backend/airshelf/settings/base.py
Normal 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")
|
||||
5
core/backend/airshelf/settings/development.py
Normal file
5
core/backend/airshelf/settings/development.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .base import * # noqa: F403
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
8
core/backend/airshelf/settings/production.py
Normal file
8
core/backend/airshelf/settings/production.py
Normal 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
|
||||
|
||||
13
core/backend/airshelf/settings/test.py
Normal file
13
core/backend/airshelf/settings/test.py
Normal 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
|
||||
17
core/backend/airshelf/urls.py
Normal file
17
core/backend/airshelf/urls.py
Normal 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")),
|
||||
]
|
||||
9
core/backend/airshelf/wsgi.py
Normal file
9
core/backend/airshelf/wsgi.py
Normal 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()
|
||||
|
||||
1
core/backend/apps/__init__.py
Normal file
1
core/backend/apps/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
core/backend/apps/accounts/__init__.py
Normal file
1
core/backend/apps/accounts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
25
core/backend/apps/accounts/admin.py
Normal file
25
core/backend/apps/accounts/admin.py
Normal 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")
|
||||
|
||||
7
core/backend/apps/accounts/apps.py
Normal file
7
core/backend/apps/accounts/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
|
||||
245
core/backend/apps/accounts/migrations/0001_initial.py
Normal file
245
core/backend/apps/accounts/migrations/0001_initial.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/accounts/migrations/__init__.py
Normal file
1
core/backend/apps/accounts/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
59
core/backend/apps/accounts/models.py
Normal file
59
core/backend/apps/accounts/models.py
Normal 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}"
|
||||
68
core/backend/apps/accounts/serializers.py
Normal file
68
core/backend/apps/accounts/serializers.py
Normal 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)
|
||||
29
core/backend/apps/accounts/tests.py
Normal file
29
core/backend/apps/accounts/tests.py
Normal 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())
|
||||
|
||||
14
core/backend/apps/accounts/urls.py
Normal file
14
core/backend/apps/accounts/urls.py
Normal 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"),
|
||||
]
|
||||
170
core/backend/apps/accounts/views.py
Normal file
170
core/backend/apps/accounts/views.py
Normal 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)
|
||||
1
core/backend/apps/ai/__init__.py
Normal file
1
core/backend/apps/ai/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
26
core/backend/apps/ai/admin.py
Normal file
26
core/backend/apps/ai/admin.py
Normal 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")
|
||||
|
||||
7
core/backend/apps/ai/apps.py
Normal file
7
core/backend/apps/ai/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.ai"
|
||||
|
||||
73
core/backend/apps/ai/catalog.py
Normal file
73
core/backend/apps/ai/catalog.py
Normal 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",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
171
core/backend/apps/ai/migrations/0001_initial.py
Normal file
171
core/backend/apps/ai/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
78
core/backend/apps/ai/migrations/0002_initial.py
Normal file
78
core/backend/apps/ai/migrations/0002_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/ai/migrations/__init__.py
Normal file
1
core/backend/apps/ai/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
102
core/backend/apps/ai/models.py
Normal file
102
core/backend/apps/ai/models.py
Normal 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}"
|
||||
|
||||
6
core/backend/apps/ai/providers/__init__.py
Normal file
6
core/backend/apps/ai/providers/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .base import AIProvider, AIProviderResult
|
||||
from .volcano import VolcanoArkProvider
|
||||
|
||||
|
||||
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]
|
||||
|
||||
19
core/backend/apps/ai/providers/base.py
Normal file
19
core/backend/apps/ai/providers/base.py
Normal 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:
|
||||
...
|
||||
|
||||
173
core/backend/apps/ai/providers/volcano.py
Normal file
173
core/backend/apps/ai/providers/volcano.py
Normal 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"
|
||||
44
core/backend/apps/ai/serializers.py
Normal file
44
core/backend/apps/ai/serializers.py
Normal 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
|
||||
|
||||
421
core/backend/apps/ai/services.py
Normal file
421
core/backend/apps/ai/services.py
Normal 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)
|
||||
12
core/backend/apps/ai/tasks.py
Normal file
12
core/backend/apps/ai/tasks.py
Normal 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
|
||||
|
||||
9
core/backend/apps/ai/urls.py
Normal file
9
core/backend/apps/ai/urls.py
Normal 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
|
||||
21
core/backend/apps/ai/views.py
Normal file
21
core/backend/apps/ai/views.py
Normal 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"]
|
||||
|
||||
1
core/backend/apps/assets/__init__.py
Normal file
1
core/backend/apps/assets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
22
core/backend/apps/assets/admin.py
Normal file
22
core/backend/apps/assets/admin.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/assets/apps.py
Normal file
7
core/backend/apps/assets/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AssetsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.assets"
|
||||
|
||||
232
core/backend/apps/assets/migrations/0001_initial.py
Normal file
232
core/backend/apps/assets/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
42
core/backend/apps/assets/migrations/0002_initial.py
Normal file
42
core/backend/apps/assets/migrations/0002_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/assets/migrations/__init__.py
Normal file
1
core/backend/apps/assets/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
100
core/backend/apps/assets/models.py
Normal file
100
core/backend/apps/assets/models.py
Normal 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)
|
||||
|
||||
51
core/backend/apps/assets/serializers.py
Normal file
51
core/backend/apps/assets/serializers.py
Normal 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)
|
||||
|
||||
52
core/backend/apps/assets/storage.py
Normal file
52
core/backend/apps/assets/storage.py
Normal 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,
|
||||
)
|
||||
11
core/backend/apps/assets/urls.py
Normal file
11
core/backend/apps/assets/urls.py
Normal 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
|
||||
61
core/backend/apps/assets/views.py
Normal file
61
core/backend/apps/assets/views.py
Normal 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)
|
||||
1
core/backend/apps/billing/__init__.py
Normal file
1
core/backend/apps/billing/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
21
core/backend/apps/billing/admin.py
Normal file
21
core/backend/apps/billing/admin.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/billing/apps.py
Normal file
7
core/backend/apps/billing/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.billing"
|
||||
|
||||
277
core/backend/apps/billing/migrations/0001_initial.py
Normal file
277
core/backend/apps/billing/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/billing/migrations/__init__.py
Normal file
1
core/backend/apps/billing/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
84
core/backend/apps/billing/models.py
Normal file
84
core/backend/apps/billing/models.py
Normal 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)
|
||||
|
||||
43
core/backend/apps/billing/serializers.py
Normal file
43
core/backend/apps/billing/serializers.py
Normal 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"]
|
||||
|
||||
1
core/backend/apps/billing/services/__init__.py
Normal file
1
core/backend/apps/billing/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
93
core/backend/apps/billing/services/ledger.py
Normal file
93
core/backend/apps/billing/services/ledger.py
Normal 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",
|
||||
)
|
||||
|
||||
60
core/backend/apps/billing/tests.py
Normal file
60
core/backend/apps/billing/tests.py
Normal 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)
|
||||
|
||||
9
core/backend/apps/billing/urls.py
Normal file
9
core/backend/apps/billing/urls.py
Normal 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"),
|
||||
]
|
||||
80
core/backend/apps/billing/views.py
Normal file
80
core/backend/apps/billing/views.py
Normal 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,
|
||||
)
|
||||
1
core/backend/apps/common/__init__.py
Normal file
1
core/backend/apps/common/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
23
core/backend/apps/common/api.py
Normal file
23
core/backend/apps/common/api.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/common/apps.py
Normal file
7
core/backend/apps/common/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
|
||||
1
core/backend/apps/common/management/__init__.py
Normal file
1
core/backend/apps/common/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
core/backend/apps/common/management/commands/__init__.py
Normal file
1
core/backend/apps/common/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -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."))
|
||||
|
||||
1
core/backend/apps/common/migrations/__init__.py
Normal file
1
core/backend/apps/common/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
33
core/backend/apps/common/models.py
Normal file
33
core/backend/apps/common/models.py
Normal 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
|
||||
|
||||
6
core/backend/apps/common/views.py
Normal file
6
core/backend/apps/common/views.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
def health_check(request):
|
||||
return JsonResponse({"status": "ok", "service": "airshelf-backend"})
|
||||
|
||||
1
core/backend/apps/ops/__init__.py
Normal file
1
core/backend/apps/ops/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
16
core/backend/apps/ops/admin.py
Normal file
16
core/backend/apps/ops/admin.py
Normal 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")
|
||||
7
core/backend/apps/ops/apps.py
Normal file
7
core/backend/apps/ops/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.ops"
|
||||
|
||||
105
core/backend/apps/ops/migrations/0001_initial.py
Normal file
105
core/backend/apps/ops/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/ops/migrations/__init__.py
Normal file
1
core/backend/apps/ops/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
84
core/backend/apps/ops/models.py
Normal file
84
core/backend/apps/ops/models.py
Normal 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
|
||||
47
core/backend/apps/ops/serializers.py
Normal file
47
core/backend/apps/ops/serializers.py
Normal 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
|
||||
9
core/backend/apps/ops/urls.py
Normal file
9
core/backend/apps/ops/urls.py
Normal 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
|
||||
167
core/backend/apps/ops/views.py
Normal file
167
core/backend/apps/ops/views.py
Normal 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)
|
||||
1
core/backend/apps/products/__init__.py
Normal file
1
core/backend/apps/products/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
22
core/backend/apps/products/admin.py
Normal file
22
core/backend/apps/products/admin.py
Normal 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]
|
||||
|
||||
7
core/backend/apps/products/apps.py
Normal file
7
core/backend/apps/products/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.products"
|
||||
|
||||
157
core/backend/apps/products/migrations/0001_initial.py
Normal file
157
core/backend/apps/products/migrations/0001_initial.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/products/migrations/__init__.py
Normal file
1
core/backend/apps/products/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
54
core/backend/apps/products/models.py
Normal file
54
core/backend/apps/products/models.py
Normal 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"]
|
||||
|
||||
65
core/backend/apps/products/serializers.py
Normal file
65
core/backend/apps/products/serializers.py
Normal 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)
|
||||
9
core/backend/apps/products/urls.py
Normal file
9
core/backend/apps/products/urls.py
Normal 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
|
||||
|
||||
14
core/backend/apps/products/views.py
Normal file
14
core/backend/apps/products/views.py
Normal 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"]
|
||||
|
||||
1
core/backend/apps/projects/__init__.py
Normal file
1
core/backend/apps/projects/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
46
core/backend/apps/projects/admin.py
Normal file
46
core/backend/apps/projects/admin.py
Normal 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)
|
||||
|
||||
7
core/backend/apps/projects/apps.py
Normal file
7
core/backend/apps/projects/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.projects"
|
||||
|
||||
721
core/backend/apps/projects/migrations/0001_initial.py
Normal file
721
core/backend/apps/projects/migrations/0001_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
1
core/backend/apps/projects/migrations/__init__.py
Normal file
1
core/backend/apps/projects/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user