Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee3722a6e | ||
|
|
5d2c63ced1 | ||
|
|
d2c85b03df | ||
|
|
33d5bfd997 | ||
|
|
15deed1cf4 | ||
|
|
7077d05d6a | ||
|
|
570292266e | ||
|
|
9b3541264f | ||
|
|
1f711fed7f | ||
|
|
2d9abd5218 | ||
|
|
cfcec301d2 | ||
|
|
3d144a4640 | ||
|
|
8d5c900411 | ||
|
|
8fbf239e01 | ||
|
|
9114b45841 | ||
|
|
0576169773 | ||
|
|
1bdafa25cb | ||
|
|
4eb0360134 | ||
|
|
ac73de70b4 | ||
|
|
0195175231 | ||
|
|
af1dd20789 | ||
|
|
da08fc43cb | ||
|
|
acac39c73c | ||
|
|
8325ae1f71 | ||
|
|
03ed98ce9b | ||
|
|
bc7eee681d | ||
|
|
663c4fa9db | ||
|
|
4685b475af | ||
|
|
357657d7cd | ||
|
|
e55077a833 | ||
|
|
0e20981383 | ||
|
|
a445198ecb | ||
|
|
42f103a945 | ||
|
|
68e902b4a4 | ||
|
|
053dde7546 | ||
|
|
cc0f8e5fd8 | ||
|
|
d5cb11dc60 | ||
|
|
7ea612d374 | ||
|
|
19d6580704 | ||
|
|
920d884e92 | ||
|
|
9463b94a3e | ||
|
|
2931e67226 | ||
|
|
06c2159b15 | ||
|
|
7ecae0eb61 | ||
|
|
54b8e1a360 | ||
|
|
4fdbe0c946 | ||
|
|
ed2e1715d1 | ||
|
|
06bf5c4db7 | ||
|
|
73243ff497 | ||
|
|
966d1ff5d3 | ||
|
|
cb682b4867 | ||
|
|
6b934b07dc | ||
|
|
6db0e2d3d5 | ||
|
|
69e7dec807 | ||
|
|
d0ccb04b07 | ||
|
|
eee8105d0e | ||
|
|
3f1c4063b0 | ||
|
|
eec5862465 | ||
|
|
6b78d5830d | ||
|
|
d111977023 | ||
|
|
b41e1d6607 | ||
|
|
def054c147 | ||
|
|
1cb957d20d | ||
|
|
a63039c146 | ||
|
|
d8c5258822 | ||
|
|
7afd5b1d5e | ||
|
|
04fad1af9d | ||
|
|
b32ed4129c | ||
|
|
9b13d01d72 | ||
|
|
4afda8992c | ||
|
|
6f37134c8c | ||
|
|
7683ebb186 | ||
|
|
0aa884304d | ||
|
|
4f1ccd3f37 | ||
|
|
79c09151ba | ||
|
|
b374a541ea | ||
|
|
56f380aa33 | ||
|
|
65fc424583 | ||
|
|
5538ddaa43 | ||
|
|
ea06edec3e | ||
|
|
6283bb8ceb | ||
|
|
fb3b95b687 | ||
|
|
3c6deed786 | ||
|
|
2a9c2d868c | ||
|
|
76d03e7de1 | ||
|
|
880e7ed515 | ||
|
|
40efcf55cf | ||
|
|
1aaef451b8 | ||
|
|
2403b38cae | ||
|
|
c0a58fdac3 | ||
|
|
c025303ef8 | ||
|
|
a2e98ef3a3 | ||
|
|
4af74acf25 | ||
|
|
d5a964a4d9 | ||
|
|
fe5a59d725 | ||
|
|
53e01f6f74 | ||
|
|
46b59ff76c | ||
|
|
c145efd0b1 | ||
|
|
4104774174 | ||
|
|
4563b686e0 | ||
|
|
e264214b77 | ||
|
|
a7d8bd25bb | ||
|
|
bbc9bd95bd | ||
|
|
d202316708 | ||
|
|
519c1e58b9 | ||
|
|
bba29d79a2 | ||
|
|
34ca98f9eb | ||
|
|
1aa7ff02f4 | ||
|
|
2d3d096fd7 | ||
|
|
8ca6f0fedb | ||
|
|
5b4449fe97 | ||
|
|
71ec6f7d47 | ||
|
|
f0ff573650 | ||
|
|
c5292fcf03 | ||
|
|
edfeea6f98 | ||
|
|
395702fbd5 | ||
|
|
098a639be6 | ||
|
|
a7c99839de | ||
|
|
6a7de32b3a | ||
|
|
3db586cd60 | ||
|
|
52f53a0c63 | ||
|
|
c80e2150f5 | ||
|
|
4d872036b2 | ||
|
|
38040dfb0d | ||
|
|
e44885461b | ||
|
|
01c55721ca | ||
|
|
085e85967a | ||
|
|
41feb480ee | ||
|
|
5036efe9aa | ||
|
|
3188d77b88 | ||
|
|
3e900a43f0 | ||
|
|
6db989dc42 | ||
|
|
b1a8c3ed5a | ||
|
|
51673453d8 | ||
|
|
7d9c50a74d | ||
|
|
0ffb35e2df | ||
|
|
9756a86304 | ||
|
|
51f9deecde | ||
|
|
a9190ba4e1 | ||
|
|
b4166ecadb | ||
|
|
4ade6bb347 | ||
|
|
9637e5ba79 | ||
|
|
21ff42653c | ||
|
|
5b937f652b | ||
|
|
b2b322bcfb | ||
|
|
dc2e527977 | ||
|
|
6ff8866ca3 | ||
|
|
6039b5ee75 | ||
|
|
adfc2aa4f4 | ||
|
|
399aefe61f | ||
|
|
4ab2baae43 | ||
|
|
fed6ba2e19 | ||
|
|
18fe13dd9f | ||
|
|
36e4bec90e | ||
|
|
af60e6720b | ||
|
|
21208e68d8 | ||
|
|
b79162654d | ||
|
|
778da67b34 | ||
|
|
a7cd5a634c | ||
|
|
0962fa7820 | ||
|
|
344fb789a4 | ||
|
|
6cc1655c69 | ||
|
|
c64cf06c33 | ||
|
|
cba3450596 | ||
|
|
39160822f7 | ||
|
|
32673f0f25 | ||
|
|
742c8688bc | ||
|
|
37588d0f05 | ||
|
|
f5f4be2986 | ||
|
|
d2a30c58ca | ||
|
|
111c74c7c6 | ||
|
|
8641d507e6 | ||
|
|
330ac27b1e | ||
|
|
508732bc76 | ||
|
|
614ae1b4d6 | ||
|
|
63e1a43b2a | ||
|
|
8270dc6500 | ||
|
|
d0bd171ab5 |
@@ -28,3 +28,4 @@ bak/
|
|||||||
# !dir/*.pycnode_modules/
|
# !dir/*.pycnode_modules/
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
*_上线.py
|
*_上线.py
|
||||||
|
.env*
|
||||||
+813
@@ -0,0 +1,813 @@
|
|||||||
|
# VideoGen 代码架构指南
|
||||||
|
|
||||||
|
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
|
||||||
|
# AI 工作规则(最高优先级)
|
||||||
|
|
||||||
|
当你执行开发任务时:
|
||||||
|
|
||||||
|
1. 不要扫描整个 Repository。
|
||||||
|
2. 不要执行全量目录探索。
|
||||||
|
3. 仅阅读本任务涉及的目录。
|
||||||
|
4. 优先按照本文档中的"快速定位表"寻找文件。
|
||||||
|
5. 如果无法定位文件,再进行有限范围搜索。
|
||||||
|
6. 单次最多搜索一级目录。
|
||||||
|
7. 不允许重复搜索已经访问过的目录。
|
||||||
|
8. 阅读完目标文件后立即开始修改代码。
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
video_item/
|
||||||
|
├── video-gen-api/ # 后端 (Python FastAPI)
|
||||||
|
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
|
||||||
|
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、后端架构 (video-gen-api)
|
||||||
|
|
||||||
|
### 2.1 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
|
||||||
|
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
|
||||||
|
│ │ ├── __init__.py # 聚合所有 v1 router
|
||||||
|
│ │ ├── auth.py
|
||||||
|
│ │ ├── projects.py
|
||||||
|
│ │ ├── generation.py
|
||||||
|
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1)
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── admin/ # 额外的管理端细分路由
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── team.py
|
||||||
|
│ ├── home_material.py
|
||||||
|
│ └── ...
|
||||||
|
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
|
||||||
|
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
|
||||||
|
│ ├── generation.py
|
||||||
|
│ ├── auth.py
|
||||||
|
│ └── ...
|
||||||
|
├── models/ # SQLAlchemy ORM 模型(41 个)
|
||||||
|
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
|
||||||
|
│ ├── user.py
|
||||||
|
│ └── ...
|
||||||
|
├── services/ # 业务逻辑层(核心业务写在这里)
|
||||||
|
│ ├── auth.py # JWT 认证、密码哈希
|
||||||
|
│ ├── credits.py # 积分扣减
|
||||||
|
│ ├── operation_log.py # 操作日志
|
||||||
|
│ └── ...
|
||||||
|
├── enums/ # 枚举定义(全部用 str, Enum)
|
||||||
|
│ ├── generation_status.py
|
||||||
|
│ ├── celery_queue.py
|
||||||
|
│ └── ...
|
||||||
|
├── tasks/ # Celery 异步任务
|
||||||
|
│ ├── celery_app.py # Celery 实例 + 配置
|
||||||
|
│ ├── cleanup.py
|
||||||
|
│ └── ...
|
||||||
|
├── middleware/ # FastAPI 中间件
|
||||||
|
│ ├── logging.py
|
||||||
|
│ ├── rate_limit.py
|
||||||
|
│ ├── request_encrypt.py
|
||||||
|
│ ├── anti_crawler.py
|
||||||
|
│ └── ...
|
||||||
|
├── utils/ # 通用工具
|
||||||
|
│ ├── id_gen.py # generate_id(), generate_order_no()
|
||||||
|
│ ├── exceptions.py # 自定义 HTTPException
|
||||||
|
│ └── security.py # AES 加密、HMAC
|
||||||
|
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
|
||||||
|
├── config.py # Pydantic Settings,读取 .env
|
||||||
|
└── main.py # FastAPI 应用入口、lifespan、种子数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 分层约定(重要)
|
||||||
|
|
||||||
|
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
|
||||||
|
|
||||||
|
```
|
||||||
|
Route (api/) → Service (services/) → Model (models/)
|
||||||
|
解析参数 业务逻辑 数据库操作
|
||||||
|
调用 service 编排调用 纯 CRUD
|
||||||
|
返回序列化结果 不直接操作 ORM 无业务规则
|
||||||
|
```
|
||||||
|
|
||||||
|
**禁止:**
|
||||||
|
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
|
||||||
|
- Model 文件中写业务逻辑 → model 只定义字段和关系
|
||||||
|
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
|
||||||
|
|
||||||
|
### 2.3 SQLAlchemy 模型约定
|
||||||
|
|
||||||
|
使用 SQLAlchemy 2.0 风格的 `mapped_column`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/models/project.py — 标准模板
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||||
|
|
||||||
|
class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
industry: Mapped[str] = mapped_column(String(32))
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex)
|
||||||
|
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
|
||||||
|
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
|
||||||
|
- 外键统一加 `index=True`,`ondelete="CASCADE"` 按需
|
||||||
|
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)` 等
|
||||||
|
- 时间统一用 `DateTime(timezone=True)`
|
||||||
|
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/models/base.py — 公共 Mixin
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class SoftDeleteMixin:
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Pydantic Schema 约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/schemas/project.py
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from app.schemas.common import NaiveDatetime
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
|
||||||
|
name: str = Field(..., max_length=128)
|
||||||
|
industry: str = Field(..., max_length=64)
|
||||||
|
|
||||||
|
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
industry: str
|
||||||
|
created_at: NaiveDatetime
|
||||||
|
updated_at: NaiveDatetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
|
||||||
|
```
|
||||||
|
|
||||||
|
**命名约定:**
|
||||||
|
- `XxxCreate` — POST 创建请求
|
||||||
|
- `XxxUpdate` — PUT 更新请求
|
||||||
|
- `XxxOut` / `XxxResponse` — 响应
|
||||||
|
- `XxxListOut` — 列表响应(含 items + total)
|
||||||
|
- `XxxRequest` — 通用请求
|
||||||
|
|
||||||
|
**公共工具:**
|
||||||
|
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
|
||||||
|
- `PaginatedResponse` — 分页响应基类
|
||||||
|
|
||||||
|
### 2.5 API 路由约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/api/v1/projects.py — 标准模板
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.schemas.project import ProjectCreate, ProjectOut
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
|
||||||
|
async def list_projects(
|
||||||
|
current_user: User = Depends(get_current_user), # 鉴权依赖
|
||||||
|
db: AsyncSession = Depends(get_db), # 数据库 session
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project).where(
|
||||||
|
Project.user_id == current_user.id,
|
||||||
|
Project.deleted_at.is_(None), # 软删除过滤
|
||||||
|
).order_by(Project.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
|
||||||
|
|
||||||
|
@router.post("", response_model=ProjectOut)
|
||||||
|
async def create_project(
|
||||||
|
req: ProjectCreate, # 请求体自动校验
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
project = Project(
|
||||||
|
id=generate_id(), # 主键手工生成
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=req.name,
|
||||||
|
industry=req.industry,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.flush() # flush 获取自增字段但不出事务
|
||||||
|
return project
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键约定:**
|
||||||
|
- `prefix` 不含 `/api`(`/api` 在 `main.py` 的 `include_router` 统一加)
|
||||||
|
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
|
||||||
|
- 管理员接口用 `Depends(get_admin_user)`
|
||||||
|
- 可选登录用 `Depends(get_optional_current_user)`
|
||||||
|
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
|
||||||
|
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
|
||||||
|
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`)
|
||||||
|
|
||||||
|
### 2.6 依赖注入约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/dependencies.py
|
||||||
|
get_db # 生成 AsyncSession,自动 commit/rollback
|
||||||
|
get_current_user # 验证 JWT → 返回 User,要求密码已设置
|
||||||
|
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
|
||||||
|
get_optional_current_user # 验证 JWT → User | None(不强制登录)
|
||||||
|
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
|
||||||
|
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 服务层约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/team_service.py — 标准模板
|
||||||
|
from __future__ import annotations # 前向引用必需的 import
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.models.team import Team
|
||||||
|
from app.schemas.team import TeamCreate, TeamUpdate
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
||||||
|
team = Team(
|
||||||
|
id=generate_id(),
|
||||||
|
name=req.name,
|
||||||
|
code=req.code,
|
||||||
|
description=req.description,
|
||||||
|
)
|
||||||
|
db.add(team)
|
||||||
|
await db.flush()
|
||||||
|
return team
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
|
||||||
|
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
|
||||||
|
- 用 `from __future__ import annotations` 支持前向引用
|
||||||
|
- 纯查询类服务可以接收 `db` 作为第一个参数
|
||||||
|
|
||||||
|
### 2.8 枚举约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/enums/generation_status.py
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
|
||||||
|
prompt_optimized = "prompt_optimized"
|
||||||
|
generating = "generating"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
class GenerationType(str, Enum):
|
||||||
|
video = "video"
|
||||||
|
image = "image"
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 所有枚举继承 `str, Enum`(可序列化为 JSON)
|
||||||
|
- 枚举值用 snake_case
|
||||||
|
- 枚举文件统一放 `app/enums/`
|
||||||
|
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
|
||||||
|
|
||||||
|
### 2.9 异常处理约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/utils/exceptions.py — 预定义异常
|
||||||
|
InsufficientCreditsError # 402 积分不足
|
||||||
|
CaptchaFailedError # 400 验证码失败
|
||||||
|
RecordNotFoundError # 404 记录不存在
|
||||||
|
ProjectNotFoundError # 404 项目不存在
|
||||||
|
InvalidStatusError # 400 状态不允许操作
|
||||||
|
|
||||||
|
# 在 service 或 route 中抛出
|
||||||
|
raise InsufficientCreditsError()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.10 认证约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Token 创建
|
||||||
|
create_access_token(user_id, remember_me=False) → str # JWT 令牌
|
||||||
|
|
||||||
|
# Token 解码
|
||||||
|
decode_access_token(token) → str | None # 返回 user_id 或 None
|
||||||
|
|
||||||
|
# 密码
|
||||||
|
hash_password(plain) → str # bcrypt 哈希
|
||||||
|
verify_password(plain, hashed) → bool # bcrypt 校验
|
||||||
|
```
|
||||||
|
|
||||||
|
**鉴权流程:**
|
||||||
|
1. 前端 Authorization header: `Bearer <token>`
|
||||||
|
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
|
||||||
|
3. 查 User 表验证 `is_active`
|
||||||
|
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
|
||||||
|
|
||||||
|
### 2.11 数据库 Session 模式
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py 中的 get_db — 自动管理事务
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit() # 成功自动提交
|
||||||
|
except Exception:
|
||||||
|
await session.rollback() # 异常自动回滚
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
|
||||||
|
|
||||||
|
### 2.12 中间件约定
|
||||||
|
|
||||||
|
中间件按**注册顺序**从外到内执行(`main.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
|
||||||
|
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
|
||||||
|
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
|
||||||
|
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
|
||||||
|
application.add_middleware(CORSMiddleware, ...) # 5. CORS
|
||||||
|
```
|
||||||
|
|
||||||
|
**加密中间件行为:**
|
||||||
|
- 请求有 `X-Encrypted: true` header → 解密请求体
|
||||||
|
- GET 请求无 body 但**响应仍加密**
|
||||||
|
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
|
||||||
|
|
||||||
|
### 2.13 Celery 任务约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/tasks/cleanup.py — 标准模板
|
||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
@celery_app.task # 装饰器注册任务
|
||||||
|
def cleanup_expired_video_urls():
|
||||||
|
"""Run hourly. Clear expired video URL tokens."""
|
||||||
|
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
|
||||||
|
|
||||||
|
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
|
||||||
|
from app.models.base import async_session # 延迟导入避免循环
|
||||||
|
async with async_session() as db:
|
||||||
|
...
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 任务装饰器: `@celery_app.task`
|
||||||
|
- 同步入口 → `asyncio.run()` 包异步逻辑
|
||||||
|
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`)
|
||||||
|
- 队列路由在 `celery_app.py` 的 `task_routes` 配置
|
||||||
|
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
|
||||||
|
|
||||||
|
### 2.14 配置约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/config.py — 基于 pydantic-settings
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
|
||||||
|
SECRET_KEY: str = "change-me"
|
||||||
|
# ... 所有配置项有默认值,生产通过 .env 覆盖
|
||||||
|
|
||||||
|
settings = Settings() # 全局单例
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 所有环境变量在 `config.py` 中有类型注解和默认值
|
||||||
|
- 通过 `settings.XXX` 全局访问
|
||||||
|
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
|
||||||
|
- 列表值用 `list[str]`(pydantic 自动解析 JSON 数组字符串)
|
||||||
|
|
||||||
|
### 2.15 操作日志约定
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
|
||||||
|
# 在路由中调用(通常在变更操作后)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建团队 {team.name}", # action 描述
|
||||||
|
"POST", # HTTP method
|
||||||
|
"/admin/teams", # path
|
||||||
|
detail=None, # 可选详情
|
||||||
|
ip=None, # 可选 IP
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、前端架构 (video-gen-app & video-gen-admin)
|
||||||
|
|
||||||
|
### 3.1 相同点
|
||||||
|
|
||||||
|
两个前端项目遵循完全相同的架构约定:
|
||||||
|
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
|
||||||
|
- Zustand 状态管理
|
||||||
|
- 统一的 API 调用层(crypto/client/index 三层)
|
||||||
|
- 路由结构 SPA(react-router-dom v7)
|
||||||
|
|
||||||
|
### 3.2 目录结构(以 video-gen-app 为例)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API 调用层
|
||||||
|
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
|
||||||
|
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API)
|
||||||
|
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
|
||||||
|
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
|
||||||
|
├── store/ # Zustand 状态
|
||||||
|
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
|
||||||
|
│ └── useAppStore.ts # 应用状态
|
||||||
|
├── types/ # TypeScript 类型定义
|
||||||
|
│ └── index.ts # 所有 interface/type
|
||||||
|
├── pages/ # 页面组件(路由对应)
|
||||||
|
│ ├── LoginPage.tsx
|
||||||
|
│ ├── ProjectsPage.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
│ ├── Layout/
|
||||||
|
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content)
|
||||||
|
│ │ └── AppLayout.css
|
||||||
|
│ ├── privatePortrait/
|
||||||
|
│ └── ...
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── formatDate.ts
|
||||||
|
│ └── ...
|
||||||
|
├── App.tsx # 路由定义 + 全局配置
|
||||||
|
└── main.tsx # 入口
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 API 调用约定(核心)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ── client.ts — 核心请求函数 ──
|
||||||
|
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
|
||||||
|
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
|
||||||
|
|
||||||
|
// 调用参数:
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: string // GET/POST/PUT/DELETE,默认 GET
|
||||||
|
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
|
||||||
|
auth?: boolean // 是否带 Authorization header,默认 true
|
||||||
|
encryptBody?: boolean // 是否加密,默认跟随全局开关
|
||||||
|
signal?: AbortSignal // 取消请求
|
||||||
|
skipAuthRedirect?: boolean // 401 时不跳转登录页
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ── index.ts — 业务 API 函数 ──
|
||||||
|
// 命名约定:动词 + 业务名,camelCase
|
||||||
|
export async function login(username: string, password: string): Promise<User> {
|
||||||
|
if (USE_MOCK) return mock.mockLogin({ username, password });
|
||||||
|
const res = await api.post<{ accessToken: string; user: User }>(
|
||||||
|
'/auth/login',
|
||||||
|
{ username, password },
|
||||||
|
false, // auth: false(登录不需要 token)
|
||||||
|
true // encryptBody: true(登录凭证需要加密)
|
||||||
|
);
|
||||||
|
setToken(res.accessToken);
|
||||||
|
return res.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 列表
|
||||||
|
export async function getProjects(): Promise<Project[]> {
|
||||||
|
if (USE_MOCK) return mock.mockGetProjects();
|
||||||
|
return api.get<Project[]>('/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 创建
|
||||||
|
export async function createProject(name: string, industry: Industry): Promise<Project> {
|
||||||
|
if (USE_MOCK) return mock.mockCreateProject(name, industry);
|
||||||
|
return api.post<Project>('/projects', { name, industry });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带查询参数的 GET
|
||||||
|
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.projectId) query.set('project_id', params.projectId);
|
||||||
|
query.set('page', String(page));
|
||||||
|
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键约定:**
|
||||||
|
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
|
||||||
|
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
|
||||||
|
- 所有 id 字段前端用 `string`(不转 number)
|
||||||
|
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`(user_id → userId)
|
||||||
|
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
|
||||||
|
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
|
||||||
|
|
||||||
|
### 3.4 状态管理约定(Zustand)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// store/useAuthStore.ts — 标准模板
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { User } from '../types';
|
||||||
|
import * as api from '../api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
login: async (username, password) => {
|
||||||
|
const user = await api.login(username, password);
|
||||||
|
set({ user });
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 全局状态用 Zustand `create`,不用 Redux
|
||||||
|
- API 调用写在 store actions 或页面中,**不要**写在 components 里
|
||||||
|
- 状态更新用 `set()`,异步操作加 `async/await`
|
||||||
|
- 组件中消费: `const { user, login } = useAuthStore()`
|
||||||
|
|
||||||
|
### 3.5 页面组件约定
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/ProjectsPage.tsx — 标准模板
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
import { getIndustries } from '../api';
|
||||||
|
import type { IndustryConfig } from '../types';
|
||||||
|
|
||||||
|
const ProjectsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getProjects(); // 调 API
|
||||||
|
setProjects(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>我的项目</Typography.Title>
|
||||||
|
{/* ... */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectsPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 页面组件是 `React.FC`,默认导出
|
||||||
|
- 业务数据类型从 `../types` 导入(`import type`)
|
||||||
|
- API 函数从 `../api` 导入
|
||||||
|
- 不用 `axios`,所有请求走 `api/index.ts`
|
||||||
|
- 用 `message.success/error/warning` 显示操作反馈
|
||||||
|
- 用 `useNavigate()` 编程式导航
|
||||||
|
- 列表加载用 `loading` 状态 + `Spin` 组件
|
||||||
|
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
|
||||||
|
|
||||||
|
### 3.6 路由约定
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.tsx — 路由结构
|
||||||
|
// 公开路由
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/join-team" element={<JoinTeamPage />} />
|
||||||
|
|
||||||
|
// 受保护路由(需要登录)
|
||||||
|
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 路由定义集中在 `App.tsx`
|
||||||
|
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`)
|
||||||
|
- 未登录用户访问受保护路由 → 跳 `/login`
|
||||||
|
- 动态路由参数用 `:paramName`
|
||||||
|
- 嵌套路由用 `<Outlet />` 渲染子页面
|
||||||
|
|
||||||
|
### 3.7 TypeScript 类型约定
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/index.ts — 全局类型定义
|
||||||
|
export interface User {
|
||||||
|
id: string; // 所有 ID 都是 string
|
||||||
|
username: string;
|
||||||
|
credits: number;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
- 全局共享类型在 `types/index.ts` 定义
|
||||||
|
- 类型/接口名 PascalCase + 业务含义
|
||||||
|
- 所有 ID、外键字段类型为 `string`
|
||||||
|
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
|
||||||
|
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
|
||||||
|
|
||||||
|
### 3.8 加密约定
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// crypto.ts
|
||||||
|
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||||
|
|
||||||
|
// 前端加密流程:
|
||||||
|
// 1. apiRequest 中如果 encryptBody=true:
|
||||||
|
// body → JSON.stringify → encrypt() → { data: "<密文>" }
|
||||||
|
// headers['X-Encrypted'] = 'true'
|
||||||
|
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
|
||||||
|
// decrypt(parsed.data) → JSON.parse → 返回
|
||||||
|
//
|
||||||
|
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
|
||||||
|
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
|
||||||
|
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.9 Mock 模式约定
|
||||||
|
|
||||||
|
当 `VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/index.ts 中每个 API 函数都检查 USE_MOCK
|
||||||
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||||
|
|
||||||
|
export async function getProjects(): Promise<Project[]> {
|
||||||
|
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
|
||||||
|
return api.get<Project[]>('/projects'); // ← 后走真实请求
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、跨项目约定(前后端协作)
|
||||||
|
|
||||||
|
### 4.1 字段名转换
|
||||||
|
|
||||||
|
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|
||||||
|
|--------------|-------------------|------|
|
||||||
|
| `snake_case` | `camelCase` | client.ts 自动转 |
|
||||||
|
| `created_at` | `createdAt` | 自动 |
|
||||||
|
| `user_id` | `userId` | 自动 |
|
||||||
|
|
||||||
|
**新增 API 时**:
|
||||||
|
- 后端 request schema 字段用 `snake_case`
|
||||||
|
- 前端 types/interface 字段用 `camelCase`
|
||||||
|
- **不需要**手动转,`client.ts` 的 `keysToCamel()` 递归转换所有 key
|
||||||
|
|
||||||
|
### 4.2 API 路径约定
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/auth/login → 登录(公开)
|
||||||
|
/api/projects → 项目管理(需登录)
|
||||||
|
/api/admin/users → 用户管理(需管理员)
|
||||||
|
/admin/teams → 团队管理(需管理员,二级路由)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/api/` 前缀统一
|
||||||
|
- 管理接口路径含 `/admin/`
|
||||||
|
- 路径用小写 + 短横线(kebab-case)
|
||||||
|
|
||||||
|
### 4.3 分页约定
|
||||||
|
|
||||||
|
后端返回:
|
||||||
|
```python
|
||||||
|
@router.get("", response_model=GenerationRecordPageListOut)
|
||||||
|
# { items: [...], total: 100, page: 1, page_size: 20 }
|
||||||
|
```
|
||||||
|
|
||||||
|
前端类型:
|
||||||
|
```typescript
|
||||||
|
export interface GenerationRecordPageListOut {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
items: GenerationRecord[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 错误处理约定
|
||||||
|
|
||||||
|
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`。
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
- `401` — token 无效/过期 → 自动跳 `/login`
|
||||||
|
- `403` — 权限不足
|
||||||
|
- `404` — 资源不存在
|
||||||
|
- `402` — 积分不足(`InsufficientCreditsError`)
|
||||||
|
- `400` — 参数错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、修改检查清单
|
||||||
|
|
||||||
|
### 新增后端接口时 ✅
|
||||||
|
|
||||||
|
- [ ] 在 `app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`)
|
||||||
|
- [ ] 在 `app/services/` 中实现业务逻辑函数
|
||||||
|
- [ ] 在 `app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`)
|
||||||
|
- [ ] 确认 `__init__.py` 中注册了新 router
|
||||||
|
- [ ] 复杂操作记录 `log_operation()`
|
||||||
|
- [ ] 写入操作后正确 `flush()` + `commit()`
|
||||||
|
|
||||||
|
### 新增后端 Model 时 ✅
|
||||||
|
|
||||||
|
- [ ] 在 `app/models/` 中新建或编辑 model 文件
|
||||||
|
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
|
||||||
|
- [ ] 主键用 `generate_id()`,外键加 `index=True`
|
||||||
|
- [ ] 在 `app/models/__init__.py` 中 import
|
||||||
|
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
|
||||||
|
- [ ] 执行 `alembic upgrade head` 应用迁移
|
||||||
|
|
||||||
|
### 新增前端页面/功能时 ✅
|
||||||
|
|
||||||
|
- [ ] 在 `types/index.ts` 中定义/补充类型
|
||||||
|
- [ ] 在 `api/index.ts` 中添加 API 函数(含 mock 分支)
|
||||||
|
- [ ] 在 `pages/` 中新建页面组件(`React.FC` + 默认导出)
|
||||||
|
- [ ] 在 `App.tsx` 中注册路由
|
||||||
|
- [ ] 受保护路由包裹 `<ProtectedRoute>`
|
||||||
|
- [ ] 在 `mock.ts` 中添加对应 mock 数据函数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、常见反模式(应避免)
|
||||||
|
|
||||||
|
| ❌ 反模式 | ✅ 正确做法 |
|
||||||
|
|----------|-----------|
|
||||||
|
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
|
||||||
|
| Model 中有业务逻辑 | Model 只定义字段和关系 |
|
||||||
|
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
|
||||||
|
| 新增 Model 不加到 `__init__.py` | 立即 import |
|
||||||
|
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
|
||||||
|
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
|
||||||
|
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
|
||||||
|
| 密码明文存储 | bcrypt 哈希(通过 `hash_password`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、快速定位表
|
||||||
|
|
||||||
|
| 想修改什么 | 去哪里 |
|
||||||
|
|-----------|--------|
|
||||||
|
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
|
||||||
|
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
|
||||||
|
| 改业务规则 | `app/services/xxx.py` |
|
||||||
|
| 改枚举值 | `app/enums/xxx.py` |
|
||||||
|
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
|
||||||
|
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
|
||||||
|
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
|
||||||
|
| 改配置项 | `app/config.py` + `.env.example` |
|
||||||
|
| 改中间件行为 | `app/middleware/xxx.py` |
|
||||||
|
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py` 的 `task_routes` |
|
||||||
+245
-70
@@ -5,11 +5,13 @@
|
|||||||
```
|
```
|
||||||
video_item/
|
video_item/
|
||||||
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
||||||
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
|
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
|
├── video-gen-admin/ # 后台管理端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
└── DEPLOYMENT.md # 本文档
|
└── DEPLOYMENT.md # 本文档
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、环境要求
|
## 一、环境要求
|
||||||
@@ -18,14 +20,13 @@ video_item/
|
|||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| Python | >= 3.10 | 推荐 3.12 |
|
| Python | >= 3.10 | 推荐 3.12 |
|
||||||
| Node.js | >= 18 | 推荐 20+ |
|
| Node.js | >= 18 | 推荐 20+ |
|
||||||
| PostgreSQL | >= 14 | 推荐 16 |
|
| PostgreSQL | >= 14 | 推荐 16;**必须** |
|
||||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
|
||||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
|
||||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
|
||||||
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
|
|
||||||
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
|
|
||||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
||||||
|
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
|
||||||
|
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
|
||||||
|
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ video_item/
|
|||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
|
||||||
# CentOS/RHEL
|
# CentOS/RHEL
|
||||||
sudo yum install -y ca-certificates
|
sudo yum install -y ca-certificates
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
@@ -49,7 +50,10 @@ python -m venv .venv
|
|||||||
# Linux/Mac
|
# Linux/Mac
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
# 安装基础依赖 + PostgreSQL 驱动
|
# 安装基础依赖
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# 安装 PostgreSQL 驱动(生产环境必须)
|
||||||
pip install -e ".[pg]"
|
pip install -e ".[pg]"
|
||||||
|
|
||||||
# 如需 Redis 支持(限流、验证码、Celery)
|
# 如需 Redis 支持(限流、验证码、Celery)
|
||||||
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
|
|||||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||||
pip install -e ".[pg,redis,celery]"
|
pip install -e ".[pg,redis,celery]"
|
||||||
|
|
||||||
#安装阿里支付sdk
|
# 如需支付宝
|
||||||
pip install -e ".[pg,redis,celery,alipay]"
|
pip install -e ".[pg,redis,celery,alipay]"
|
||||||
|
|
||||||
# 安装微信支付sdk
|
# 如需微信支付
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
|
pip install -e ".[pg,redis,celery,alipay,wxpay]"
|
||||||
|
|
||||||
#安装火山sdk
|
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
|
|
||||||
|
|
||||||
|
|
||||||
|
# 如需火山引擎 SDK(短信等)
|
||||||
|
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置环境变量
|
### 2. 配置环境变量
|
||||||
|
|
||||||
复制 `.env.example` 为 `.env`,修改以下关键配置:
|
复制 `.env.example` 为 `.env`,修改关键配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -81,13 +83,14 @@ cp .env.example .env
|
|||||||
```ini
|
```ini
|
||||||
# ── 基础配置 ──
|
# ── 基础配置 ──
|
||||||
APP_NAME=VideoGen API
|
APP_NAME=VideoGen API
|
||||||
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
SECRET_KEY=改成一个随机的长字符串
|
SECRET_KEY=改成一个随机的长字符串(JWT 签名密钥)
|
||||||
|
|
||||||
# ── 数据库 ──
|
# ── 数据库(必须) ──
|
||||||
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
||||||
|
|
||||||
# ── Redis(可选,留空则禁用限流和验证码) ──
|
# ── Redis(可选,留空则禁用限流/验证码/Celery) ──
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# ── JWT ──
|
# ── JWT ──
|
||||||
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
|||||||
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
||||||
|
|
||||||
# ── LLM 提示词优化 ──
|
# ── LLM 提示词优化 ──
|
||||||
LLM_MOCK=false
|
LLM_API_BASE=https://api.openai.com/v1
|
||||||
|
LLM_API_KEY=
|
||||||
|
LLM_MODEL=gpt-4o
|
||||||
|
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
|
||||||
|
|
||||||
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
||||||
ENCRYPTION_KEY=你的32字节base64密钥
|
ENCRYPTION_KEY=你的32字节base64密钥
|
||||||
|
|
||||||
# ── 存储路径 ──
|
# ── 存储路径(本地存储) ──
|
||||||
STORAGE_TYPE=local
|
STORAGE_TYPE=local
|
||||||
STORAGE_LOCAL_PATH=./storage/generate/videos
|
STORAGE_LOCAL_PATH=./storage/generate/videos
|
||||||
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
||||||
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
||||||
UPLOAD_LOCAL_PATH=./storage/uploads
|
UPLOAD_LOCAL_PATH=./storage/uploads
|
||||||
|
|
||||||
# ── 跨域(生产环境务必限制域名) ──
|
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有) ──
|
||||||
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
||||||
|
|
||||||
# ── 回调基础地址 ──
|
# ── 回调基础地址 ──
|
||||||
BASE_URL=https://你的域名
|
BASE_URL=https://你的域名
|
||||||
|
|
||||||
|
# ── 验证码 ──
|
||||||
|
CAPTCHA_ENABLED=true
|
||||||
|
|
||||||
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
||||||
SMS_MOCK=true
|
SMS_MOCK=true
|
||||||
VOLC_SMS_ACCESS_KEY_ID=
|
VOLC_SMS_ACCESS_KEY_ID=
|
||||||
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
|
|||||||
|
|
||||||
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
||||||
PAYMENT_MOCK=true
|
PAYMENT_MOCK=true
|
||||||
|
WECHAT_MCH_ID=
|
||||||
|
WECHAT_API_KEY=
|
||||||
|
ALIPAY_APP_ID=
|
||||||
|
ALIPAY_PRIVATE_KEY=
|
||||||
|
ALIPAY_PUBLIC_KEY=
|
||||||
|
ALIPAY_NOTIFY_URL=
|
||||||
|
|
||||||
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||||
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
|||||||
FFMPEG_BIN=/usr/bin/ffmpeg
|
FFMPEG_BIN=/usr/bin/ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
|
||||||
|
|
||||||
### 3. 初始化数据库
|
### 3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建 PostgreSQL 数据库
|
# 创建 PostgreSQL 数据库
|
||||||
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
||||||
|
|
||||||
# 启动后端(首次启动自动建表 + 填充种子数据)
|
# 启动后端(首次启动自动建表)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动会自动完成:
|
首次启动时会**自动创建所有数据表**(41 个 model)。
|
||||||
- 创建所有数据表(23 个 model)
|
|
||||||
- 创建管理员账号:`admin` / `123456`
|
|
||||||
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`)
|
|
||||||
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
|
|
||||||
|
|
||||||
**首次部署后务必修改默认密码。**
|
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**(`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
|
||||||
|
>
|
||||||
|
> **如果你需要种子数据,**有以下选择:
|
||||||
|
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
|
||||||
|
> 2. 手动通过 API 或数据库脚本创建管理员账号
|
||||||
|
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
|
||||||
|
|
||||||
### 4. 数据库迁移 (Alembic)
|
### 4. 数据库迁移 (Alembic)
|
||||||
|
|
||||||
项目使用 Alembic 管理数据库结构变更,`env.py` 已导入全部 20 个 model。
|
项目使用 Alembic 管理数据库结构变更。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
@@ -179,17 +197,13 @@ python -m alembic history
|
|||||||
python -m alembic downgrade -1
|
python -m alembic downgrade -1
|
||||||
```
|
```
|
||||||
|
|
||||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||||
|
|
||||||
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
|
|
||||||
|
|
||||||
### 5. 生产运行
|
### 5. 生产运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式一:直接运行(推荐 4 workers)
|
# 直接运行(推荐 4 workers)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
|
||||||
# 方式二:使用 systemd (Linux)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
||||||
@@ -220,11 +234,22 @@ sudo systemctl start videogen-api
|
|||||||
|
|
||||||
### 6. Celery Worker(可选)
|
### 6. Celery Worker(可选)
|
||||||
|
|
||||||
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
|
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
||||||
|
|
||||||
|
Celery 使用 **6 个队列**,按功能分离:
|
||||||
|
|
||||||
|
| 队列 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
||||||
|
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
||||||
|
| `gen_result_download` | 下载生成的视频/图片结果 |
|
||||||
|
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
||||||
|
| `gen_private_portrait` | 真人素材认证与同步 |
|
||||||
|
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动 Worker(处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download)
|
# 启动 Worker(消费所有队列)
|
||||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||||
```
|
```
|
||||||
|
|
||||||
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||||
@@ -239,7 +264,7 @@ Type=simple
|
|||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/opt/video-gen-api
|
WorkingDirectory=/opt/video-gen-api
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -249,23 +274,25 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### 7. Docker 部署(可选)
|
### 7. Docker 部署(可选)
|
||||||
|
|
||||||
项目提供 `Dockerfile` 和 `docker-compose.yml`,一键启动完整环境:
|
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||||
|
|
||||||
|
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
|
|
||||||
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
|
# 使用前需修改 Dockerfile 第 6 行为:
|
||||||
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
|
|
||||||
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动的服务:
|
启动的服务:
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
| 服务 | 端口 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| api | 8000 | FastAPI 应用(带 `--reload`,开发模式) |
|
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
|
||||||
| worker | - | Celery Worker |
|
| worker | - | Celery Worker(消费所有队列) |
|
||||||
| postgres | 5432 | PostgreSQL 16 |
|
| postgres | 5432 | PostgreSQL 16 |
|
||||||
| redis | 6379 | Redis 7 |
|
| redis | 6379 | Redis 7 |
|
||||||
|
|
||||||
@@ -276,7 +303,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name api.yourdomain.com;
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
# 上传文件大小限制
|
# 上传文件大小限制(视频上传需要较大值)
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -300,6 +327,8 @@ server {
|
|||||||
|
|
||||||
## 三、前台部署 (video-gen-app)
|
## 三、前台部署 (video-gen-app)
|
||||||
|
|
||||||
|
用户前台,面向最终用户。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -310,8 +339,8 @@ npm install
|
|||||||
# 配置 API 地址(创建 .env.production)
|
# 配置 API 地址(创建 .env.production)
|
||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||||
|
|
||||||
# 如需前后端加密通信
|
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
|
||||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||||
|
|
||||||
# 构建
|
# 构建
|
||||||
npm run build
|
npm run build
|
||||||
@@ -319,7 +348,15 @@ npm run build
|
|||||||
|
|
||||||
构建产物在 `dist/` 目录。
|
构建产物在 `dist/` 目录。
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 前端环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -328,12 +365,12 @@ server {
|
|||||||
root /opt/video-gen-app/dist;
|
root /opt/video-gen-app/dist;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA 路由
|
# SPA 路由:所有页面请求回退到 index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存(带 hash 的文件名可长期缓存)
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -345,6 +382,8 @@ server {
|
|||||||
|
|
||||||
## 四、后台管理部署 (video-gen-admin)
|
## 四、后台管理部署 (video-gen-admin)
|
||||||
|
|
||||||
|
管理员后台,面向运营/管理人员。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -356,13 +395,21 @@ npm install
|
|||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||||
|
|
||||||
# 如需前后端加密通信
|
# 如需前后端加密通信
|
||||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||||
|
|
||||||
# 构建
|
# 构建
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 后台环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -456,6 +503,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
|
|
||||||
## 七、默认账号
|
## 七、默认账号
|
||||||
|
|
||||||
|
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
|
||||||
|
|
||||||
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
||||||
|------|--------|--------|------|------|
|
|------|--------|--------|------|------|
|
||||||
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
||||||
@@ -467,32 +516,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
|
|
||||||
## 八、架构说明
|
## 八、架构说明
|
||||||
|
|
||||||
### 异步任务处理
|
### 后端启动时的后台任务
|
||||||
|
|
||||||
系统有两套异步任务机制:
|
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务:
|
||||||
|
|
||||||
| 机制 | 说明 | 依赖 |
|
| 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|
|
||||||
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
|
||||||
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
|
| `upload_queue` | 上传任务队列 |
|
||||||
|
| `material_consumption_queue` | 素材消耗队列 |
|
||||||
|
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
|
||||||
|
| `poll_pre_test_results` | 每分钟轮询前测结果 |
|
||||||
|
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
|
||||||
|
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
|
||||||
|
|
||||||
### 中间件栈(从外到内)
|
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
|
||||||
|
|
||||||
|
### 中间件栈(从外到内,即请求到达的顺序)
|
||||||
|
|
||||||
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
||||||
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
||||||
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
||||||
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
||||||
5. `CORSMiddleware` — 跨域
|
5. `CORSMiddleware` — 跨域(expose `X-Encrypted` 响应头)
|
||||||
|
|
||||||
### 日志系统
|
### 前后端通信加密
|
||||||
|
|
||||||
| 日志类型 | 目录 | 控制方式 |
|
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
|
||||||
|
|
||||||
|
**生效条件:** 同时满足以下两个条件才启用加密:
|
||||||
|
- 后端 `.env` 中 `ENCRYPTION_KEY` 非空
|
||||||
|
- 前端 `.env` 中 `VITE_ENCRYPTION_KEY` 非空
|
||||||
|
- 运行在安全上下文(HTTPS 或 localhost)
|
||||||
|
|
||||||
|
**加密范围:**
|
||||||
|
|
||||||
|
| 请求类型 | 请求体 | 响应体 |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| GET(无 body) | 不加密(无内容) | **加密** |
|
||||||
|
| POST/PUT/DELETE(有 body) | **加密** | **加密** |
|
||||||
|
|
||||||
|
**工作流:**
|
||||||
|
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
|
||||||
|
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
|
||||||
|
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
|
||||||
|
|
||||||
|
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
|
||||||
|
|
||||||
|
### 文件日志加密(独立机制)
|
||||||
|
|
||||||
|
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
|
||||||
|
|
||||||
|
| 日志类型 | 目录 | 加密方式 |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
|
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
|
|
||||||
|
|
||||||
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点。
|
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面。
|
||||||
|
|
||||||
|
### 内部管理端点
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/internal/` | 后端入口导航页 |
|
||||||
|
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
|
||||||
|
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
|
||||||
|
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
|
||||||
|
| `/internal/api-docs` | Swagger API 文档 |
|
||||||
|
| `/internal/api-redoc` | ReDoc API 文档 |
|
||||||
|
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
|
||||||
|
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
|
||||||
|
|
||||||
|
### 前端 Mock 模式
|
||||||
|
|
||||||
|
前端支持 Mock 数据模式,无需后端即可开发:
|
||||||
|
|
||||||
|
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
|
||||||
|
- 适用场景:纯前端开发、演示、无后端环境
|
||||||
|
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
|
||||||
|
|
||||||
### 存储路径
|
### 存储路径
|
||||||
|
|
||||||
@@ -522,6 +623,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
│ ├── .env # 环境变量
|
│ ├── .env # 环境变量
|
||||||
│ ├── .venv/ # Python 虚拟环境
|
│ ├── .venv/ # Python 虚拟环境
|
||||||
│ ├── app/ # 应用代码
|
│ ├── app/ # 应用代码
|
||||||
|
│ │ ├── api/ # API 路由
|
||||||
|
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
|
||||||
|
│ │ │ └── admin/ # 管理端接口
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ ├── models/ # 数据模型 (41 个)
|
||||||
|
│ │ ├── services/ # 业务服务
|
||||||
|
│ │ ├── tasks/ # Celery 任务
|
||||||
|
│ │ ├── enums/ # 枚举定义
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
│ ├── alembic/ # 数据库迁移文件
|
│ ├── alembic/ # 数据库迁移文件
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ │ ├── generate/
|
│ │ ├── generate/
|
||||||
@@ -565,4 +675,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
|
|||||||
|
|
||||||
# 生成迁移文件
|
# 生成迁移文件
|
||||||
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
|
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
|
||||||
|
|
||||||
|
# 查看 Celery 活动任务
|
||||||
|
celery -A app.tasks.celery_app inspect active
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、环境变量完整参考
|
||||||
|
|
||||||
|
### 后端 (video-gen-api/.env)
|
||||||
|
|
||||||
|
| 变量 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
|
||||||
|
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
|
||||||
|
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
|
||||||
|
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
|
||||||
|
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
|
||||||
|
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
|
||||||
|
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
|
||||||
|
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
|
||||||
|
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
|
||||||
|
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
|
||||||
|
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
|
||||||
|
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
|
||||||
|
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
|
||||||
|
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
|
||||||
|
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
|
||||||
|
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
|
||||||
|
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
|
||||||
|
| `STORAGE_TYPE` | 否 | local | 存储类型 |
|
||||||
|
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
|
||||||
|
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
|
||||||
|
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
|
||||||
|
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery) |
|
||||||
|
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
|
||||||
|
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
|
||||||
|
|
||||||
|
### 前台 (video-gen-app/.env.production)
|
||||||
|
|
||||||
|
| 变量 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
|
||||||
|
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
|
||||||
|
|
||||||
|
### 后台管理 (video-gen-admin/.env.production)
|
||||||
|
|
||||||
|
同前台。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、故障排查
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 解决方案 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
|
||||||
|
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
|
||||||
|
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
|
||||||
|
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
|
||||||
|
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
|
||||||
|
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
|
||||||
|
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
|
||||||
|
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
|
||||||
|
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
|
||||||
|
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# VITE_API_BASE=http://192.168.120.17:8000
|
# VITE_API_BASE=http://192.168.120.17:8000
|
||||||
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
#VITE_API_BASE=https://apiforeign.minzhongzc.com
|
||||||
|
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
# Encryption disabled for dev — enable in production
|
# Encryption disabled for dev — enable in production
|
||||||
VITE_ENCRYPTION_KEY=
|
VITE_ENCRYPTION_KEY=
|
||||||
Vendored
+175
-132
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
@import "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";:root{--font-sans:"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--nav-bg:#08080c;--nav-surface:#ffffff0a;--nav-border:#ffffff0f;--nav-text:#e8e8ec;--nav-text-muted:#8b8fa3;--nav-hover:#ffffff14;--nav-active:#ffffff1f}*,:before,:after{box-sizing:border-box}html,body{font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#1a1a2e;letter-spacing:-.01em;background:#f8f9fc;margin:0;padding:0}#root{min-height:100vh}
|
|
||||||
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-3wUbVp5v.js"></script>
|
<script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>␍
|
||||||
|
|||||||
Generated
+82
@@ -13,6 +13,7 @@
|
|||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
"xlsx-js-style": "^1.2.0",
|
"xlsx-js-style": "^1.2.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
@@ -1428,6 +1429,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exit-on-epipe": {
|
"node_modules/exit-on-epipe": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||||
@@ -1437,6 +1444,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1761,6 +1774,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
@@ -1780,6 +1812,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1841,6 +1879,35 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -1868,6 +1935,21 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-quill-new": {
|
||||||
|
"version": "3.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||||
|
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"quill": "~2.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"quill-delta": "^5.1.0",
|
||||||
|
"react": "^16 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.15.0",
|
"version": "7.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
"xlsx-js-style": "^1.2.0",
|
"xlsx-js-style": "^1.2.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import AdminModels from './pages/AdminModels';
|
|||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||||
import AdminModelPricingRules from './pages/AdminModelPricingRules';
|
|
||||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||||
import AdminPaymentStats from './pages/AdminPaymentStats';
|
import AdminPaymentStats from './pages/AdminPaymentStats';
|
||||||
import AdminIndustries from './pages/AdminIndustries';
|
import AdminIndustries from './pages/AdminIndustries';
|
||||||
@@ -33,6 +32,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
|
|||||||
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
||||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||||
|
import AdminVideoUpscale from './pages/AdminVideoUpscale';
|
||||||
import AdminContactRequests from './pages/AdminContactRequests';
|
import AdminContactRequests from './pages/AdminContactRequests';
|
||||||
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||||
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||||
@@ -88,7 +88,6 @@ const App = () => {
|
|||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
<Route path="teams" element={<AdminTeams />} />
|
<Route path="teams" element={<AdminTeams />} />
|
||||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||||
<Route path="model-pricing" element={<AdminModelPricingRules />} />
|
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||||
<Route path="video-engines" element={<AdminVideoEngines />} />
|
<Route path="video-engines" element={<AdminVideoEngines />} />
|
||||||
@@ -100,6 +99,7 @@ const App = () => {
|
|||||||
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
||||||
<Route path="settings" element={<AdminSettings />} />
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
||||||
|
<Route path="video-upscale" element={<AdminVideoUpscale />} />
|
||||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import type {
|
|||||||
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
||||||
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
||||||
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||||
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
|
|
||||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||||
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||||
|
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -42,6 +42,18 @@ import type {
|
|||||||
HomeMaterialWatermarkQueryParams,
|
HomeMaterialWatermarkQueryParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
|
||||||
|
// ── Video Upscale ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getVideoUpscaleConfig(): Promise<VideoUpscaleConfigOut> {
|
||||||
|
return api.get<VideoUpscaleConfigOut>('/admin/video-upscale/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVideoUpscaleConfig(payload: VideoUpscaleConfigSavePayload): Promise<VideoUpscaleConfigOut> {
|
||||||
|
return api.put<VideoUpscaleConfigOut>('/admin/video-upscale/config', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
||||||
@@ -225,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
|
|||||||
await api.put(`/admin/system-configs/${id}`, { value });
|
await api.put(`/admin/system-configs/${id}`, { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
|
||||||
|
return api.post('/admin/system-configs', { key, value, description });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||||
return api.get('/admin/resource-capacity/global');
|
return api.get('/admin/resource-capacity/global');
|
||||||
}
|
}
|
||||||
@@ -277,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
|
|||||||
return { url: res.url };
|
return { url: res.url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.detail || '上传失败');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||||
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
||||||
}
|
}
|
||||||
@@ -294,57 +326,16 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
|
|||||||
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
||||||
setMaybe(params, 'media_type', filters?.mediaType);
|
setMaybe(params, 'media_type', filters?.mediaType);
|
||||||
setMaybe(params, 'charge_kind', filters?.chargeKind);
|
setMaybe(params, 'charge_kind', filters?.chargeKind);
|
||||||
|
setMaybe(params, 'charge_action', filters?.chargeAction);
|
||||||
setMaybe(params, 'source_module', filters?.sourceModule);
|
setMaybe(params, 'source_module', filters?.sourceModule);
|
||||||
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
||||||
setMaybe(params, 'billing_scene', filters?.billingScene);
|
setMaybe(params, 'billing_scene', filters?.billingScene);
|
||||||
setMaybe(params, 'engine_provider', filters?.engineProvider);
|
|
||||||
setMaybe(params, 'engine_model_name', filters?.engineModelName);
|
|
||||||
setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode);
|
|
||||||
setMaybe(params, 'provider_cost_status', filters?.providerCostStatus);
|
|
||||||
setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated);
|
|
||||||
setMaybe(params, 'has_attachment', filters?.hasAttachment);
|
|
||||||
setMaybe(params, 'start_date', filters?.startDate);
|
setMaybe(params, 'start_date', filters?.startDate);
|
||||||
setMaybe(params, 'end_date', filters?.endDate);
|
setMaybe(params, 'end_date', filters?.endDate);
|
||||||
const q = params.toString() ? `?${params}` : '';
|
const q = params.toString() ? `?${params}` : '';
|
||||||
return api.get(`/admin/credit-records${q}`);
|
return api.get(`/admin/credit-records${q}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getModelPricingRules(filters?: {
|
|
||||||
page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string;
|
|
||||||
}): Promise<ModelPricingRuleListResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
setMaybe(params, 'page', filters?.page);
|
|
||||||
setMaybe(params, 'page_size', filters?.pageSize);
|
|
||||||
setMaybe(params, 'provider', filters?.provider);
|
|
||||||
setMaybe(params, 'model_name', filters?.modelName);
|
|
||||||
setMaybe(params, 'model_category', filters?.modelCategory);
|
|
||||||
setMaybe(params, 'publish_status', filters?.publishStatus);
|
|
||||||
return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise<ModelPricingRule> {
|
|
||||||
return api.post('/admin/model-pricing/rules', payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateModelPricingRule(id: string, payload: Partial<ModelPricingRulePayload>): Promise<ModelPricingRule> {
|
|
||||||
return api.put(`/admin/model-pricing/rules/${id}`, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishModelPricingRule(id: string): Promise<ModelPricingRule> {
|
|
||||||
return api.post(`/admin/model-pricing/rules/${id}/publish`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disableModelPricingRule(id: string): Promise<ModelPricingRule> {
|
|
||||||
return api.post(`/admin/model-pricing/rules/${id}/disable`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function previewModelPricing(payload: {
|
|
||||||
billing_mode: string; calculator_version: string; rule_json: Record<string, any>; usage: Record<string, any>; currency?: string;
|
|
||||||
}): Promise<ModelPricingPreviewResponse> {
|
|
||||||
return api.post('/admin/model-pricing/preview', payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIndustryConfigs(): Promise<any[]> {
|
export async function getIndustryConfigs(): Promise<any[]> {
|
||||||
return api.get('/admin/industry-configs');
|
return api.get('/admin/industry-configs');
|
||||||
}
|
}
|
||||||
@@ -358,8 +349,9 @@ export async function deleteIndustryConfig(id: string): Promise<void> {
|
|||||||
await api.delete(`/admin/industry-configs/${id}`);
|
await api.delete(`/admin/industry-configs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVideoEngines(): Promise<any[]> {
|
export async function getVideoEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
|
||||||
return api.get('/admin/video-engines');
|
const query = options?.includeDeleted ? '?include_deleted=true' : '';
|
||||||
|
return api.get(`/admin/video-engines${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveVideoEngine(engine: any): Promise<any> {
|
export async function saveVideoEngine(engine: any): Promise<any> {
|
||||||
@@ -371,8 +363,9 @@ export async function deleteVideoEngine(id: string): Promise<void> {
|
|||||||
await api.delete(`/admin/video-engines/${id}`);
|
await api.delete(`/admin/video-engines/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getImageEngines(): Promise<any[]> {
|
export async function getImageEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
|
||||||
return api.get('/admin/image-engines');
|
const query = options?.includeDeleted ? '?include_deleted=true' : '';
|
||||||
|
return api.get(`/admin/image-engines${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImageEngine(engine: any): Promise<any> {
|
export async function saveImageEngine(engine: any): Promise<any> {
|
||||||
@@ -430,10 +423,11 @@ export async function getPaymentStats(params?: {
|
|||||||
return api.get(url);
|
return api.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
|
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.method) qs.set('payment_method', params.method);
|
if (params?.method) qs.set('payment_method', params.method);
|
||||||
if (params?.status) qs.set('status', params.status);
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.phone) qs.set('phone', params.phone);
|
||||||
if (params?.startDate) qs.set('start_date', params.startDate);
|
if (params?.startDate) qs.set('start_date', params.startDate);
|
||||||
if (params?.endDate) qs.set('end_date', params.endDate);
|
if (params?.endDate) qs.set('end_date', params.endDate);
|
||||||
if (params?.page) qs.set('page', String(params.page));
|
if (params?.page) qs.set('page', String(params.page));
|
||||||
@@ -625,28 +619,26 @@ export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
|
|||||||
// ── Generation Records (Admin) ─────────────────────────────
|
// ── Generation Records (Admin) ─────────────────────────────
|
||||||
|
|
||||||
export async function getAdminGenerationRecords(params?: {
|
export async function getAdminGenerationRecords(params?: {
|
||||||
userId?: string; status?: string; page?: number; pageSize?: number;
|
userId?: string;
|
||||||
|
status?: string;
|
||||||
|
engineId?: string;
|
||||||
|
includeMediaReferences?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
}): Promise<{ total: number; items: any[] }> {
|
}): Promise<{ total: number; items: any[] }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params?.userId) q.set('user_id', params.userId);
|
if (params?.userId) q.set('user_id', params.userId);
|
||||||
if (params?.status) q.set('status', params.status);
|
if (params?.status) q.set('status', params.status);
|
||||||
|
if (params?.engineId) q.set('engine_id', params.engineId);
|
||||||
|
if (params?.includeMediaReferences !== undefined) {
|
||||||
|
q.set('include_media_references', String(params.includeMediaReferences));
|
||||||
|
}
|
||||||
if (params?.page) q.set('page', String(params.page));
|
if (params?.page) q.set('page', String(params.page));
|
||||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
|
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminUpdateGenerationStatus(
|
|
||||||
recordId: string, status: string, videoUrl?: string
|
|
||||||
): Promise<void> {
|
|
||||||
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function adminGenerateVideo(
|
|
||||||
recordId: string, aspectRatio: string, resolution: string, image_size: string
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution, image_size });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generation AI Engines (Admin) ─────────────────────────────
|
// ── Generation AI Engines (Admin) ─────────────────────────────
|
||||||
|
|
||||||
@@ -692,8 +684,13 @@ export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryP
|
|||||||
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
|
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
export async function getAdminHotOpeningTaskDetail(
|
||||||
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
|
projectId: string,
|
||||||
|
flowVersion: 'v1' | 'v2',
|
||||||
|
): Promise<ReplicationProjectDetailOut> {
|
||||||
|
return flowVersion === 'v2'
|
||||||
|
? api.get<ReplicationProjectDetailOut>(`/v2/hot-opening-replications/tasks/${projectId}`)
|
||||||
|
: api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
|
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
|
||||||
@@ -732,8 +729,13 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
|
|||||||
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
|
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
export async function getAdminShotProjectDetail(
|
||||||
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
projectId: string,
|
||||||
|
flowVersion: 'v1' | 'v2',
|
||||||
|
): Promise<ReplicationProjectDetailOut> {
|
||||||
|
return flowVersion === 'v2'
|
||||||
|
? api.get<ReplicationProjectDetailOut>(`/v2/shot-replications/projects/${projectId}`)
|
||||||
|
: api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Empty, Spin, Tag, Typography } from 'antd';
|
||||||
|
import { PlayCircleFilled } from '@ant-design/icons';
|
||||||
|
import type { GenerationAITaskOut } from '../../types';
|
||||||
|
import { resolveGenerationUiState } from '../../utils/generationTaskStatus';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: GenerationAITaskOut;
|
||||||
|
resolveUrl: (url?: string | null) => string;
|
||||||
|
onPreview: (url: string, type: 'image' | 'video', title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanByCount = (count: number, index: number): number => {
|
||||||
|
if (count <= 1) return 6;
|
||||||
|
if (count === 2 || count === 4) return 3;
|
||||||
|
if (count === 3) return index < 2 ? 3 : 6;
|
||||||
|
return index < 3 ? 2 : 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, resolveUrl, onPreview }) => {
|
||||||
|
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
|
||||||
|
const sortedChildren = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
|
||||||
|
const items: GenerationAITaskOut[] = sortedChildren.length
|
||||||
|
? sortedChildren
|
||||||
|
: (count > 1
|
||||||
|
? Array.from({ length: count }, (_, index) => ({ ...task, id: `${task.id}-${index + 1}`, generationIndex: index + 1, childItems: [] }))
|
||||||
|
: [task]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: 430, display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0,1fr))', gridAutoRows: 'minmax(0,1fr)', gap: items.length > 1 ? 8 : 0 }}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const uiState = resolveGenerationUiState(item);
|
||||||
|
const isVideo = item.genType === 'video';
|
||||||
|
const resultUrl = resolveUrl(isVideo ? item.videoUrl : item.imageUrl);
|
||||||
|
const coverUrl = resolveUrl(item.videoCoverUrl);
|
||||||
|
const active = uiState.isActive;
|
||||||
|
return (
|
||||||
|
<div key={item.id} style={{ gridColumn: `span ${spanByCount(items.length, index)}`, minWidth: 0, minHeight: 0, border: '1px solid #edf0f5', borderRadius: 10, overflow: 'hidden', position: 'relative', background: '#f8f9fc' }}>
|
||||||
|
{resultUrl && uiState.isSuccess ? (
|
||||||
|
<button type="button" onClick={() => onPreview(resultUrl, isVideo ? 'video' : 'image', `生成结果 ${item.generationIndex || index + 1}`)} style={{ width: '100%', height: '100%', padding: 0, border: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}>
|
||||||
|
{isVideo ? (coverUrl ? <img src={coverUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : <video src={resultUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />) : <img src={resultUrl} alt="生成图片" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||||
|
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', color: '#fff', fontSize: 38, filter: 'drop-shadow(0 3px 8px rgba(0,0,0,.35))' }} /> : null}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9, padding: 12, textAlign: 'center' }}>
|
||||||
|
{active ? <Spin size="small" /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={null} />}
|
||||||
|
<Tag color={uiState.color}>{uiState.label}</Tag>
|
||||||
|
{item.errorMessage && !active ? <Typography.Text type="danger" style={{ fontSize: 11 }}>{item.errorMessage}</Typography.Text> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.length > 1 ? <span style={{ position: 'absolute', top: 6, left: 6, padding: '1px 7px', borderRadius: 10, color: '#fff', background: 'rgba(17,24,39,.58)', fontSize: 11 }}>#{item.generationIndex || index + 1}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerationTaskResourceGrid;
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import type {
|
|
||||||
ModelPricingBillingMode,
|
|
||||||
ModelPricingCalculatorVersion,
|
|
||||||
ModelPricingRule,
|
|
||||||
ModelPricingRulePayload,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initial?: ModelPricingRule | null;
|
|
||||||
loading?: boolean;
|
|
||||||
onSubmit: (payload: ModelPricingRulePayload) => Promise<void> | void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeOptions = [
|
|
||||||
{ value: 'text_token_tiered', label: '文本分档 Token 计价' },
|
|
||||||
{ value: 'image_per_output', label: '按输出图片数量计价' },
|
|
||||||
{ value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' },
|
|
||||||
{ value: 'video_token_rate', label: '视频像素 Token 计价' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const calculatorByMode: Record<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
|
|
||||||
text_token_tiered: 'text_token_tiered_v1',
|
|
||||||
image_per_output: 'image_per_output_v1',
|
|
||||||
image_input_output_tiered: 'image_input_output_tiered_v1',
|
|
||||||
video_token_rate: 'video_pixel_token_v1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const billByOptions = [
|
|
||||||
{ value: 'successful_output_count', label: '实际成功输出数' },
|
|
||||||
{ value: 'requested_output_count', label: '请求输出数(估算)' },
|
|
||||||
{ value: 'provider_billed_count', label: '供应商明确计费数' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function splitList(value?: string): string[] {
|
|
||||||
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
|
|
||||||
return source[camelKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
|
|
||||||
const result = { ...source };
|
|
||||||
keys.forEach(key => delete result[key]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, [
|
|
||||||
'max_context_tokens', 'maxContextTokens',
|
|
||||||
'input_rate', 'inputRate',
|
|
||||||
'audio_input_rate', 'audioInputRate',
|
|
||||||
'output_rate', 'outputRate',
|
|
||||||
'cached_input_rate', 'cachedInputRate',
|
|
||||||
'cached_audio_input_rate', 'cachedAudioInputRate',
|
|
||||||
]);
|
|
||||||
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
|
|
||||||
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
|
|
||||||
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
|
|
||||||
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
|
|
||||||
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
|
|
||||||
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
|
|
||||||
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
max_pixels: maxPixels === undefined ? null : maxPixels,
|
|
||||||
rate: value?.rate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, [
|
|
||||||
'has_input_video', 'hasInputVideo',
|
|
||||||
'generate_audio', 'generateAudio',
|
|
||||||
'inference_modes', 'inferenceModes',
|
|
||||||
]);
|
|
||||||
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
|
|
||||||
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
|
|
||||||
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
|
|
||||||
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
|
|
||||||
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。
|
|
||||||
* 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。
|
|
||||||
*/
|
|
||||||
function normalizeRuleJson(
|
|
||||||
mode: ModelPricingBillingMode,
|
|
||||||
value: Record<string, any>,
|
|
||||||
): Record<string, any> {
|
|
||||||
const source = value || {};
|
|
||||||
const base = removeKeys(source, [
|
|
||||||
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
|
|
||||||
'output_rate', 'outputRate',
|
|
||||||
'bill_by', 'billBy',
|
|
||||||
'free_input_images', 'freeInputImages',
|
|
||||||
'input_image_rate', 'inputImageRate',
|
|
||||||
'output_tiers', 'outputTiers',
|
|
||||||
'token_formula', 'tokenFormula',
|
|
||||||
'default_fps', 'defaultFps',
|
|
||||||
'supported_resolutions', 'supportedResolutions',
|
|
||||||
'dimension_map', 'dimensionMap',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mode === 'text_token_tiered') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
cache_storage_rate_per_million_token_hour: readAlias(
|
|
||||||
source,
|
|
||||||
'cache_storage_rate_per_million_token_hour',
|
|
||||||
'cacheStorageRatePerMillionTokenHour',
|
|
||||||
),
|
|
||||||
tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'image_per_output') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
output_rate: readAlias(source, 'output_rate', 'outputRate'),
|
|
||||||
bill_by: readAlias(source, 'bill_by', 'billBy'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'image_input_output_tiered') {
|
|
||||||
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
|
|
||||||
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
|
|
||||||
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
|
|
||||||
bill_by: readAlias(source, 'bill_by', 'billBy'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
|
|
||||||
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
|
|
||||||
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
|
|
||||||
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
|
|
||||||
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimensionRows(rule: Record<string, any>): Array<Record<string, any>> {
|
|
||||||
const rows: Array<Record<string, any>> = [];
|
|
||||||
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
|
|
||||||
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
|
|
||||||
const item = value as Record<string, any>;
|
|
||||||
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
|
|
||||||
const normalized = normalizeRuleJson(mode, rule || {});
|
|
||||||
if (mode === 'text_token_tiered') {
|
|
||||||
return {
|
|
||||||
tiers: normalized.tiers || [],
|
|
||||||
cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === 'image_per_output') {
|
|
||||||
return {
|
|
||||||
outputRate: normalized.output_rate ?? 0,
|
|
||||||
billBy: normalized.bill_by || 'successful_output_count',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === 'image_input_output_tiered') {
|
|
||||||
return {
|
|
||||||
freeInputImages: normalized.free_input_images ?? 1,
|
|
||||||
inputImageRate: normalized.input_image_rate ?? 0,
|
|
||||||
outputTiers: normalized.output_tiers || [],
|
|
||||||
billBy: normalized.bill_by || 'successful_output_count',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tokenFormula: normalized.token_formula
|
|
||||||
|| '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
|
|
||||||
supportedResolutions: (normalized.supported_resolutions || []).join(','),
|
|
||||||
defaultFps: normalized.default_fps ?? 30,
|
|
||||||
dimensionRows: dimensionRows(normalized),
|
|
||||||
rates: (normalized.rates || []).map((r: any) => ({
|
|
||||||
...r,
|
|
||||||
resolutions: (r.resolutions || []).join(','),
|
|
||||||
inferenceModes: (r.inference_modes || []).join(','),
|
|
||||||
hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false',
|
|
||||||
generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false',
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDimensionMap(rows: Array<Record<string, any>>): Record<string, any> {
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
(rows || []).forEach(row => {
|
|
||||||
const resolution = String(row.resolution || '').trim().toLowerCase();
|
|
||||||
const aspectRatio = String(row.aspectRatio || '').trim();
|
|
||||||
const width = Number(row.width || 0);
|
|
||||||
const height = Number(row.height || 0);
|
|
||||||
if (!resolution || !aspectRatio || width <= 0 || height <= 0) return;
|
|
||||||
result[resolution] ||= {};
|
|
||||||
result[resolution][aspectRatio] = { width, height };
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRuleJson(
|
|
||||||
mode: ModelPricingBillingMode,
|
|
||||||
pricing: Record<string, any>,
|
|
||||||
baseRule: Record<string, any>,
|
|
||||||
): Record<string, any> {
|
|
||||||
const base = normalizeRuleJson(mode, baseRule || {});
|
|
||||||
if (mode === 'text_token_tiered') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
unit: 'CNY_per_million_tokens',
|
|
||||||
cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0),
|
|
||||||
tiers: (pricing.tiers || []).map((v: any) => ({
|
|
||||||
max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined
|
|
||||||
? null
|
|
||||||
: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'),
|
|
||||||
input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
|
|
||||||
audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
|
|
||||||
output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0),
|
|
||||||
cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
|
|
||||||
cached_audio_input_rate: String(
|
|
||||||
readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate')
|
|
||||||
?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate')
|
|
||||||
?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate')
|
|
||||||
?? readAlias(v || {}, 'input_rate', 'inputRate')
|
|
||||||
?? 0,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === 'image_per_output') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
unit: 'CNY_per_image',
|
|
||||||
output_rate: String(pricing.outputRate ?? 0),
|
|
||||||
bill_by: pricing.billBy || 'successful_output_count',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === 'image_input_output_tiered') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
unit: 'CNY_per_image',
|
|
||||||
free_input_images: Number(pricing.freeInputImages || 0),
|
|
||||||
input_image_rate: String(pricing.inputImageRate ?? 0),
|
|
||||||
output_tiers: (pricing.outputTiers || []).map((v: any) => ({
|
|
||||||
max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined
|
|
||||||
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === null
|
|
||||||
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === ''
|
|
||||||
? null
|
|
||||||
: Number(readAlias(v || {}, 'max_pixels', 'maxPixels')),
|
|
||||||
rate: String(v.rate ?? 0),
|
|
||||||
})),
|
|
||||||
bill_by: pricing.billBy || 'successful_output_count',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
unit: 'CNY_per_million_tokens',
|
|
||||||
token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
|
|
||||||
default_fps: Number(pricing.defaultFps || 30),
|
|
||||||
supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()),
|
|
||||||
dimension_map: buildDimensionMap(pricing.dimensionRows || []),
|
|
||||||
rates: (pricing.rates || []).map((v: any) => ({
|
|
||||||
...(splitList(v.resolutions).length
|
|
||||||
? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) }
|
|
||||||
: {}),
|
|
||||||
...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined
|
|
||||||
? { has_input_video: v.hasInputVideo === 'true' }
|
|
||||||
: {}),
|
|
||||||
...(v.generateAudio !== 'any' && v.generateAudio !== undefined
|
|
||||||
? { generate_audio: v.generateAudio === 'true' }
|
|
||||||
: {}),
|
|
||||||
...(splitList(v.inferenceModes).length
|
|
||||||
? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) }
|
|
||||||
: {}),
|
|
||||||
rate: String(v.rate ?? 0),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PricingRuleForm: React.FC<Props> = ({ initial, loading, onSubmit, onCancel }) => {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const billingMode = initial?.billingMode || 'video_token_rate';
|
|
||||||
form.setFieldsValue({
|
|
||||||
provider: initial?.provider || 'volcengine',
|
|
||||||
modelName: initial?.modelName || '',
|
|
||||||
modelCategory: initial?.modelCategory || 'video',
|
|
||||||
billingMode,
|
|
||||||
calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode],
|
|
||||||
versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`,
|
|
||||||
effectiveRange: [
|
|
||||||
dayjs(initial?.effectiveFrom || undefined),
|
|
||||||
initial?.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
|
||||||
],
|
|
||||||
currency: initial?.currency || 'CNY',
|
|
||||||
ruleSchemaVersion: initial?.ruleSchemaVersion || 1,
|
|
||||||
sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106',
|
|
||||||
sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null,
|
|
||||||
remark: initial?.remark || '',
|
|
||||||
pricing: toFormPricing(billingMode, initial?.ruleJson || {}),
|
|
||||||
});
|
|
||||||
}, [initial, form]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mode) return;
|
|
||||||
form.setFieldValue('calculatorVersion', calculatorByMode[mode]);
|
|
||||||
}, [mode, form]);
|
|
||||||
|
|
||||||
const submit = async (values: any) => {
|
|
||||||
const billingMode = values.billingMode as ModelPricingBillingMode;
|
|
||||||
const [from, to] = values.effectiveRange || [];
|
|
||||||
const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {};
|
|
||||||
await onSubmit({
|
|
||||||
provider: values.provider,
|
|
||||||
model_name: values.modelName,
|
|
||||||
model_category: values.modelCategory,
|
|
||||||
billing_mode: billingMode,
|
|
||||||
calculator_version: calculatorByMode[billingMode],
|
|
||||||
version_code: values.versionCode,
|
|
||||||
effective_from: from.toISOString(),
|
|
||||||
effective_to: to ? to.toISOString() : null,
|
|
||||||
currency: values.currency || 'CNY',
|
|
||||||
rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1),
|
|
||||||
rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}),
|
|
||||||
source_url: values.sourceUrl || null,
|
|
||||||
source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null,
|
|
||||||
remark: values.remark || null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
|
|
||||||
/>
|
|
||||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
|
||||||
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
|
|
||||||
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
|
|
||||||
</Space>
|
|
||||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
|
||||||
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
|
|
||||||
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
|
|
||||||
<Select options={modeOptions} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
|
|
||||||
</Space>
|
|
||||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
|
||||||
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
|
|
||||||
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
|
|
||||||
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
|
|
||||||
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Divider titlePlacement="start">价格参数</Divider>
|
|
||||||
{mode === 'text_token_tiered' && <>
|
|
||||||
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
|
|
||||||
<InputNumber min={0} stringMode style={{ width: 260 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.List name={['pricing', 'tiers']}>
|
|
||||||
{(fields, { add, remove }) => <>
|
|
||||||
{fields.map(field => (
|
|
||||||
<Space key={field.key} align="baseline" wrap>
|
|
||||||
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
<Button onClick={() => add({})}>新增 Token 档位</Button>
|
|
||||||
</>}
|
|
||||||
</Form.List>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{mode === 'image_per_output' && <Space align="baseline">
|
|
||||||
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
|
|
||||||
<InputNumber min={0} stringMode style={{ width: 220 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
|
|
||||||
</Space>}
|
|
||||||
|
|
||||||
{mode === 'image_input_output_tiered' && <>
|
|
||||||
<Space align="baseline">
|
|
||||||
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
|
|
||||||
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
|
|
||||||
</Space>
|
|
||||||
<Form.List name={['pricing', 'outputTiers']}>
|
|
||||||
{(fields, { add, remove }) => <>
|
|
||||||
{fields.map(field => <Space key={field.key} align="baseline">
|
|
||||||
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
|
||||||
</Space>)}
|
|
||||||
<Button onClick={() => add({ max_pixels: null, rate: 0 })}>新增输出像素档位</Button>
|
|
||||||
</>}
|
|
||||||
</Form.List>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
{mode === 'video_token_rate' && <>
|
|
||||||
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
|
|
||||||
<Space align="baseline" wrap>
|
|
||||||
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
|
|
||||||
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
|
|
||||||
</Space>
|
|
||||||
<Divider titlePlacement="start" plain>分辨率 + 比例对应真实像素</Divider>
|
|
||||||
<Form.List name={['pricing', 'dimensionRows']}>
|
|
||||||
{(fields, { add, remove }) => <>
|
|
||||||
{fields.map(field => <Space key={field.key} align="baseline" wrap>
|
|
||||||
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
|
|
||||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
|
||||||
</Space>)}
|
|
||||||
<Button onClick={() => add({})}>新增尺寸映射</Button>
|
|
||||||
</>}
|
|
||||||
</Form.List>
|
|
||||||
<Divider titlePlacement="start" plain>视频价格档位</Divider>
|
|
||||||
<Form.List name={['pricing', 'rates']}>
|
|
||||||
{(fields, { add, remove }) => <>
|
|
||||||
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
|
|
||||||
<Space align="baseline" wrap>
|
|
||||||
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
|
|
||||||
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
|
|
||||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
|
||||||
</Space>
|
|
||||||
</div>)}
|
|
||||||
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}>新增视频价格档位</Button>
|
|
||||||
</>}
|
|
||||||
</Form.List>
|
|
||||||
</>}
|
|
||||||
|
|
||||||
<Divider titlePlacement="start">来源与备注</Divider>
|
|
||||||
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
|
|
||||||
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
|
|
||||||
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
|
|
||||||
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<Button onClick={onCancel}>取消</Button>
|
|
||||||
<Button type="primary" htmlType="submit" loading={loading}>保存草稿</Button>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PricingRuleForm;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
|
|
||||||
import { previewModelPricing } from '../../api';
|
|
||||||
import type {
|
|
||||||
ModelPricingBillingMode,
|
|
||||||
ModelPricingCalculatorVersion,
|
|
||||||
ModelPricingPreviewResponse,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
billingMode: ModelPricingBillingMode;
|
|
||||||
calculatorVersion: ModelPricingCalculatorVersion;
|
|
||||||
ruleJson: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
|
|
||||||
text_token_tiered: {
|
|
||||||
input_tokens: 1000,
|
|
||||||
output_tokens: 300,
|
|
||||||
cached_input_tokens: 0,
|
|
||||||
audio_input_tokens: 0,
|
|
||||||
usage_source: 'provider',
|
|
||||||
},
|
|
||||||
image_per_output: {
|
|
||||||
requested_output_count: 1,
|
|
||||||
successful_output_count: 1,
|
|
||||||
provider_billed_count: 1,
|
|
||||||
usage_source: 'provider_response',
|
|
||||||
},
|
|
||||||
image_input_output_tiered: {
|
|
||||||
provider_input_image_count: 2,
|
|
||||||
requested_output_count: 1,
|
|
||||||
successful_output_count: 1,
|
|
||||||
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
|
|
||||||
usage_source: 'provider_response',
|
|
||||||
},
|
|
||||||
video_token_rate: {
|
|
||||||
total_tokens: 1000000,
|
|
||||||
resolution: '720p',
|
|
||||||
aspect_ratio: '9:16',
|
|
||||||
has_input_video: false,
|
|
||||||
generate_audio: false,
|
|
||||||
inference_mode: 'online',
|
|
||||||
usage_source: 'provider',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
|
|
||||||
return source[camelKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
|
|
||||||
const result = { ...source };
|
|
||||||
keys.forEach(key => delete result[key]);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, [
|
|
||||||
'max_context_tokens', 'maxContextTokens',
|
|
||||||
'input_rate', 'inputRate',
|
|
||||||
'audio_input_rate', 'audioInputRate',
|
|
||||||
'output_rate', 'outputRate',
|
|
||||||
'cached_input_rate', 'cachedInputRate',
|
|
||||||
'cached_audio_input_rate', 'cachedAudioInputRate',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
|
|
||||||
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
|
|
||||||
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
|
|
||||||
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
|
|
||||||
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
|
|
||||||
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
|
|
||||||
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
max_pixels: maxPixels === undefined ? null : maxPixels,
|
|
||||||
rate: value?.rate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
|
|
||||||
const result = removeKeys(value || {}, [
|
|
||||||
'has_input_video', 'hasInputVideo',
|
|
||||||
'generate_audio', 'generateAudio',
|
|
||||||
'inference_modes', 'inferenceModes',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
|
|
||||||
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
|
|
||||||
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
|
|
||||||
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
|
|
||||||
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
|
|
||||||
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
|
|
||||||
*/
|
|
||||||
function normalizeRuleJson(
|
|
||||||
billingMode: ModelPricingBillingMode,
|
|
||||||
value: Record<string, any>,
|
|
||||||
): Record<string, any> {
|
|
||||||
const source = value || {};
|
|
||||||
const base = removeKeys(source, [
|
|
||||||
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
|
|
||||||
'output_rate', 'outputRate',
|
|
||||||
'bill_by', 'billBy',
|
|
||||||
'free_input_images', 'freeInputImages',
|
|
||||||
'input_image_rate', 'inputImageRate',
|
|
||||||
'output_tiers', 'outputTiers',
|
|
||||||
'token_formula', 'tokenFormula',
|
|
||||||
'default_fps', 'defaultFps',
|
|
||||||
'supported_resolutions', 'supportedResolutions',
|
|
||||||
'dimension_map', 'dimensionMap',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (billingMode === 'text_token_tiered') {
|
|
||||||
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
cache_storage_rate_per_million_token_hour: readAlias(
|
|
||||||
source,
|
|
||||||
'cache_storage_rate_per_million_token_hour',
|
|
||||||
'cacheStorageRatePerMillionTokenHour',
|
|
||||||
),
|
|
||||||
tiers: tiers.map(item => normalizeTextTier(item || {})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingMode === 'image_per_output') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
output_rate: readAlias(source, 'output_rate', 'outputRate'),
|
|
||||||
bill_by: readAlias(source, 'bill_by', 'billBy'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingMode === 'image_input_output_tiered') {
|
|
||||||
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
|
|
||||||
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
|
|
||||||
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
|
|
||||||
bill_by: readAlias(source, 'bill_by', 'billBy'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
|
|
||||||
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
|
|
||||||
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
|
|
||||||
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
|
|
||||||
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
|
|
||||||
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
|
|
||||||
const [usageText, setUsageText] = useState(initial);
|
|
||||||
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUsageText(initial);
|
|
||||||
setResult(null);
|
|
||||||
}, [initial, ruleJson, calculatorVersion]);
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const usage = JSON.parse(usageText);
|
|
||||||
setResult(await previewModelPricing({
|
|
||||||
billing_mode: billingMode,
|
|
||||||
calculator_version: calculatorVersion,
|
|
||||||
rule_json: normalizeRuleJson(billingMode, ruleJson),
|
|
||||||
usage,
|
|
||||||
currency: 'CNY',
|
|
||||||
}));
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '计价试算失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
计算器:{calculatorVersion}。图片使用同步响应中的实际输出条目;视频优先使用供应商实际 Token。
|
|
||||||
</Typography.Text>
|
|
||||||
<Input.TextArea
|
|
||||||
value={usageText}
|
|
||||||
onChange={e => setUsageText(e.target.value)}
|
|
||||||
rows={10}
|
|
||||||
style={{ marginTop: 10, fontFamily: 'monospace' }}
|
|
||||||
/>
|
|
||||||
<Space style={{ marginTop: 10 }}>
|
|
||||||
<Button type="primary" loading={loading} onClick={run}>开始试算</Button>
|
|
||||||
</Space>
|
|
||||||
{result && <Alert
|
|
||||||
style={{ marginTop: 12 }}
|
|
||||||
type={result.isEstimated ? 'warning' : 'success'}
|
|
||||||
showIcon
|
|
||||||
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
|
|
||||||
description={<>
|
|
||||||
<div style={{ marginBottom: 8 }}>用量来源:{result.usageSource || '-'}</div>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
|
|
||||||
</>}
|
|
||||||
/>}
|
|
||||||
</Card>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PricingRulePreview;
|
|
||||||
@@ -30,7 +30,7 @@ const AdminContactRequests: React.FC = () => {
|
|||||||
const { user } = useAdminStore();
|
const { user } = useAdminStore();
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!user?.isAdmin) return;
|
// if (!user?.isAdmin) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -39,7 +39,7 @@ const AdminContactRequests: React.FC = () => {
|
|||||||
if (isHandledFilter !== null) {
|
if (isHandledFilter !== null) {
|
||||||
query.set('is_handled', String(isHandledFilter));
|
query.set('is_handled', String(isHandledFilter));
|
||||||
}
|
}
|
||||||
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/contact/requests?${query.toString()}`);
|
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/admin/contact-requests?${query.toString()}`);
|
||||||
setData(res.items);
|
setData(res.items);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -55,7 +55,7 @@ const AdminContactRequests: React.FC = () => {
|
|||||||
|
|
||||||
const handleMarkHandled = async (id: string) => {
|
const handleMarkHandled = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/contact/requests/${id}/handle`);
|
await api.put(`/admin/contact-requests/${id}/handle`);
|
||||||
message.success('已标记为处理');
|
message.success('已标记为处理');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -65,7 +65,7 @@ const AdminContactRequests: React.FC = () => {
|
|||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/contact/requests/${id}`);
|
await api.delete(`/admin/contact-requests/${id}`);
|
||||||
message.success('已删除');
|
message.success('已删除');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography,
|
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { getCreditRecords, getTeamOptions } from '../api';
|
import { getCreditRecords, getTeamOptions } from '../api';
|
||||||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
@@ -14,15 +14,21 @@ import { formatDate } from '../utils/formatDate';
|
|||||||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
|
|
||||||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||||
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
|
totalRecharge: 0,
|
||||||
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
|
totalConsume: 0,
|
||||||
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
|
totalRefund: 0,
|
||||||
totalTokens: 0, inputTokens: 0, outputTokens: 0,
|
transactionCount: 0,
|
||||||
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
|
generationCount: 0,
|
||||||
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
|
generationAttemptCount: 0,
|
||||||
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
|
imageGenerationCount: 0,
|
||||||
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
|
videoGenerationCount: 0,
|
||||||
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0,
|
imageConsume: 0,
|
||||||
|
videoConsume: 0,
|
||||||
|
textConsume: 0,
|
||||||
|
analysisConsume: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||||
@@ -32,39 +38,91 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
|
|||||||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
|
||||||
|
charge: { text: '真实扣费', color: 'red' },
|
||||||
|
refund: { text: '真实退款', color: 'blue' },
|
||||||
|
hold: { text: '预扣占用', color: 'gold' },
|
||||||
|
hold_release: { text: '预扣释放', color: 'green' },
|
||||||
|
};
|
||||||
|
|
||||||
const userScopeOptions = [
|
const userScopeOptions = [
|
||||||
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
|
{ value: '', label: '全部用户' },
|
||||||
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
|
{ value: 'admin', label: '后台用户' },
|
||||||
|
{ value: 'frontend_internal', label: '前台内部用户' },
|
||||||
|
{ value: 'frontend_external', label: '前台外部用户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const recordTypeOptions = [
|
const recordTypeOptions = [
|
||||||
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
|
{ value: '', label: '全部流水' },
|
||||||
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
|
{ value: 'recharge', label: '充值' },
|
||||||
|
{ value: 'consume', label: '消费' },
|
||||||
|
{ value: 'refund', label: '回退' },
|
||||||
|
{ value: 'team_internal', label: '团队内部' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const creditSubjectOptions = [
|
const creditSubjectOptions = [
|
||||||
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
|
{ value: '', label: '全部积分类型' },
|
||||||
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
|
{ value: 'media', label: '图片/视频生成积分' },
|
||||||
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
|
{ value: 'text', label: '提词优化积分' },
|
||||||
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
|
{ value: 'module', label: '模块功能积分' },
|
||||||
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', label: '历史未知' },
|
{ value: 'analysis', label: '分析积分' },
|
||||||
|
{ value: 'split', label: '切片积分' },
|
||||||
|
{ value: 'admin_adjust', label: '管理员调整' },
|
||||||
|
{ value: 'team_internal', label: '团队内部转移' },
|
||||||
|
{ value: 'recharge', label: '充值积分' },
|
||||||
|
{ value: 'unknown', label: '历史未知' },
|
||||||
];
|
];
|
||||||
const mediaTypeOptions = [{ value: '', label: '全部媒体' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }];
|
|
||||||
|
const mediaTypeOptions = [
|
||||||
|
{ value: '', label: '全部媒体' },
|
||||||
|
{ value: 'image', label: '图片' },
|
||||||
|
{ value: 'video', label: '视频' },
|
||||||
|
];
|
||||||
|
|
||||||
const chargeKindOptions = [
|
const chargeKindOptions = [
|
||||||
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
|
{ value: '', label: '全部扣费子类' },
|
||||||
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
|
{ value: 'media', label: '媒体生成' },
|
||||||
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
|
{ value: 'text_prompt', label: '提词优化' },
|
||||||
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', label: '管理员调整' },
|
{ value: 'file_parse', label: '文件解析' },
|
||||||
|
{ value: 'vision_input', label: '图片理解' },
|
||||||
|
{ value: 'module_create', label: '创建模块项目' },
|
||||||
|
{ value: 'video_analysis', label: '视频分析' },
|
||||||
|
{ value: 'video_split', label: '视频切片' },
|
||||||
|
{ value: 'admin_adjust', label: '管理员调整' },
|
||||||
{ value: 'team_internal', label: '团队内部转移' },
|
{ value: 'team_internal', label: '团队内部转移' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const chargeActionOptions = [
|
||||||
|
{ value: '', label: '全部交易动作' },
|
||||||
|
{ value: 'charge', label: '真实扣费' },
|
||||||
|
{ value: 'refund', label: '真实退款' },
|
||||||
|
{ value: 'hold', label: '预扣占用' },
|
||||||
|
{ value: 'hold_release', label: '预扣释放' },
|
||||||
|
];
|
||||||
|
|
||||||
const sourceModuleOptions = [
|
const sourceModuleOptions = [
|
||||||
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
|
{ value: '', label: '全部模块' },
|
||||||
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
|
{ value: 'ai_creation', label: 'AI创作' },
|
||||||
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
|
{ value: 'generation_record', label: '项目记录' },
|
||||||
|
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
|
||||||
|
{ value: 'shot_replicate', label: '拆镜复刻' },
|
||||||
|
{ value: 'admin', label: '后台管理' },
|
||||||
|
{ value: 'payment', label: '支付充值' },
|
||||||
|
{ value: 'team', label: '团队管理' },
|
||||||
|
{ value: 'unknown', label: '历史未知' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sourceStepOptions = [
|
const sourceStepOptions = [
|
||||||
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
|
{ value: '', label: '全部步骤' },
|
||||||
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
|
{ value: 'image_prompt_optimize', label: '图片提词优化' },
|
||||||
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
|
{ value: 'image_generate', label: '图片生成' },
|
||||||
|
{ value: 'video_prompt_optimize', label: '视频提词优化' },
|
||||||
|
{ value: 'video_generate', label: '视频生成' },
|
||||||
|
{ value: 'video_analysis', label: '视频分析' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const billingSceneOptions = [
|
const billingSceneOptions = [
|
||||||
{ value: '', label: '全部计费场景' },
|
{ value: '', label: '全部计费场景' },
|
||||||
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
||||||
@@ -94,45 +152,24 @@ const billingSceneOptions = [
|
|||||||
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
||||||
{ value: 'unknown', label: '历史未知' },
|
{ value: 'unknown', label: '历史未知' },
|
||||||
];
|
];
|
||||||
const costStatusOptions = [
|
|
||||||
{ value: '', label: '全部成本状态' }, { value: 'pending', label: '待回填' }, { value: 'calculated', label: '已核算' },
|
|
||||||
{ value: 'estimated', label: '估算' }, { value: 'unmatched_rule', label: '未匹配价格' },
|
|
||||||
{ value: 'usage_missing', label: '用量缺失' }, { value: 'historical_price_unavailable', label: '历史价格缺失' },
|
|
||||||
{ value: 'historical_engine_unavailable', label: '历史引擎缺失' },
|
|
||||||
{ value: 'provider_result_uncertain', label: '供应商结果不确定' },
|
|
||||||
{ value: 'not_incurred', label: '供应商费用未发生' },
|
|
||||||
{ value: 'error', label: '核算异常' }, { value: 'not_applicable', label: '不涉及成本' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function n(value: number | string | undefined | null, digits = 0): string {
|
function n(value: number | undefined | null): string {
|
||||||
const parsed = Number(value || 0);
|
return Number(value || 0).toLocaleString();
|
||||||
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function engineTypeLabel(type?: string): string {
|
function engineTypeLabel(type?: string): string {
|
||||||
if (type === 'model') return '提词/分析模型';
|
if (type === 'model') return '提词/分析模型';
|
||||||
if (type === 'image') return '图片引擎';
|
if (type === 'image') return '图片引擎';
|
||||||
if (type === 'video') return '视频引擎';
|
if (type === 'video') return '视频引擎';
|
||||||
return '执行配置';
|
return '执行配置';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
||||||
if (scope === 'admin') return { userType: 'admin' };
|
if (scope === 'admin') return { userType: 'admin' };
|
||||||
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
||||||
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function costStatusColor(status?: string): string {
|
|
||||||
if (status === 'calculated') return 'green';
|
|
||||||
if (status === 'estimated') return 'orange';
|
|
||||||
if (status === 'pending') return 'blue';
|
|
||||||
if (status === 'not_applicable') return 'default';
|
|
||||||
return 'red';
|
|
||||||
}
|
|
||||||
|
|
||||||
const JsonBlock: React.FC<{ value?: Record<string, any> | null }> = ({ value }) => (
|
|
||||||
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
|
|
||||||
{value ? JSON.stringify(value, null, 2) : '-'}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AdminCreditRecords: React.FC = () => {
|
const AdminCreditRecords: React.FC = () => {
|
||||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||||
@@ -143,7 +180,6 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const [exportProgress, setExportProgress] = useState('');
|
const [exportProgress, setExportProgress] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
|
|
||||||
|
|
||||||
const [userScope, setUserScope] = useState('');
|
const [userScope, setUserScope] = useState('');
|
||||||
const [teamFilter, setTeamFilter] = useState('');
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
@@ -152,28 +188,30 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const [creditSubject, setCreditSubject] = useState('');
|
const [creditSubject, setCreditSubject] = useState('');
|
||||||
const [mediaType, setMediaType] = useState('');
|
const [mediaType, setMediaType] = useState('');
|
||||||
const [chargeKind, setChargeKind] = useState('');
|
const [chargeKind, setChargeKind] = useState('');
|
||||||
|
const [chargeAction, setChargeAction] = useState('');
|
||||||
const [sourceModule, setSourceModule] = useState('');
|
const [sourceModule, setSourceModule] = useState('');
|
||||||
const [sourceStepCode, setSourceStepCode] = useState('');
|
const [sourceStepCode, setSourceStepCode] = useState('');
|
||||||
const [billingScene, setBillingScene] = useState('');
|
const [billingScene, setBillingScene] = useState('');
|
||||||
const [userNameFilter, setUserNameFilter] = useState('');
|
const [userNameFilter, setUserNameFilter] = useState('');
|
||||||
const [engineProvider, setEngineProvider] = useState('');
|
|
||||||
const [engineModelName, setEngineModelName] = useState('');
|
|
||||||
const [pricingVersionCode, setPricingVersionCode] = useState('');
|
|
||||||
const [providerCostStatus, setProviderCostStatus] = useState('');
|
|
||||||
const [hasAttachment, setHasAttachment] = useState('');
|
|
||||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||||
|
|
||||||
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
||||||
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
|
page,
|
||||||
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
|
pageSize,
|
||||||
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
|
userName: userNameFilter || undefined,
|
||||||
|
teamId: teamFilter || undefined,
|
||||||
|
recordType: recordType || undefined,
|
||||||
|
creditSubject: creditSubject || undefined,
|
||||||
|
mediaType: mediaType || undefined,
|
||||||
|
chargeKind: chargeKind || undefined,
|
||||||
|
chargeAction: chargeAction || undefined,
|
||||||
|
sourceModule: sourceModule || undefined,
|
||||||
|
sourceStepCode: sourceStepCode || undefined,
|
||||||
billingScene: billingScene || undefined,
|
billingScene: billingScene || undefined,
|
||||||
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
|
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||||
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
|
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||||
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
|
|
||||||
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
|
||||||
...buildScope(userScope),
|
...buildScope(userScope),
|
||||||
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]);
|
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -181,20 +219,34 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const res = await getCreditRecords(query);
|
const res = await getCreditRecords(query);
|
||||||
setRecords(res.items || []);
|
setRecords(res.items || []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
|
setSummary(res.summary || DEFAULT_SUMMARY);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载积分记录失败');
|
message.error(e?.message || '加载积分记录失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [query]);
|
useEffect(() => { load(); }, [query]);
|
||||||
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
|
setUserScope('');
|
||||||
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
|
setTeamFilter('');
|
||||||
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
|
setRecordType('');
|
||||||
|
setCreditSubject('');
|
||||||
|
setMediaType('');
|
||||||
|
setChargeKind('');
|
||||||
|
setChargeAction('');
|
||||||
|
setSourceModule('');
|
||||||
|
setSourceStepCode('');
|
||||||
|
setBillingScene('');
|
||||||
|
setUserNameFilter('');
|
||||||
|
setDateRange([null, null]);
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportExcel = async () => {
|
const exportExcel = async () => {
|
||||||
@@ -205,179 +257,171 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
||||||
const first = await getCreditRecords(baseQuery);
|
const first = await getCreditRecords(baseQuery);
|
||||||
const all: AdminCreditRecord[] = [...(first.items || [])];
|
const all: AdminCreditRecord[] = [...(first.items || [])];
|
||||||
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
|
const exportSummary = first.summary || DEFAULT_SUMMARY;
|
||||||
const totalRows = first.total || 0;
|
const totalRows = first.total || 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
||||||
|
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
|
||||||
for (let p = 2; p <= totalPages; p += 1) {
|
for (let p = 2; p <= totalPages; p += 1) {
|
||||||
const res = await getCreditRecords({ ...baseQuery, page: p });
|
const res = await getCreditRecords({ ...baseQuery, page: p });
|
||||||
all.push(...(res.items || []));
|
all.push(...(res.items || []));
|
||||||
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
||||||
}
|
}
|
||||||
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
|
|
||||||
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
|
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
|
||||||
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
|
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
|
||||||
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
|
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
|
||||||
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
|
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
||||||
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
|
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||||
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
|
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||||
{ title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' },
|
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
||||||
{ title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' },
|
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||||
{ title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
|
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
|
||||||
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
|
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
||||||
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
|
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
||||||
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
|
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
|
||||||
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
|
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
|
||||||
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
|
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
|
||||||
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
|
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
|
||||||
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
|
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
|
||||||
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
|
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||||||
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
|
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
|
||||||
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
|
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
|
||||||
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
|
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
|
||||||
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
|
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
|
||||||
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
|
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
|
||||||
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
|
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
|
||||||
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
|
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
|
||||||
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '否' },
|
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
|
||||||
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
|
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
|
||||||
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '否' },
|
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
||||||
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
|
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
||||||
{ title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' },
|
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||||
{ title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' },
|
|
||||||
{ title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' },
|
|
||||||
{ title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' },
|
|
||||||
];
|
];
|
||||||
exportStyledExcel({
|
|
||||||
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
|
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
|
||||||
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
|
exportStyledExcel<AdminCreditRecord>({
|
||||||
|
filename,
|
||||||
|
sheetName: '积分流水',
|
||||||
|
title: '积分流水汇总',
|
||||||
|
metadataRows: [
|
||||||
|
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
|
||||||
|
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||||||
|
['导出条数', totalRows],
|
||||||
|
],
|
||||||
summaryRows: [
|
summaryRows: [
|
||||||
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
|
['总充值', exportSummary.totalRecharge],
|
||||||
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
|
['总消费', exportSummary.totalConsume],
|
||||||
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
|
['总回退', exportSummary.totalRefund],
|
||||||
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
|
['交易笔数', exportSummary.transactionCount],
|
||||||
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
|
['生成条数', exportSummary.generationCount],
|
||||||
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
|
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||||
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
|
['图片生成条数', exportSummary.imageGenerationCount],
|
||||||
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
|
['视频生成条数', exportSummary.videoGenerationCount],
|
||||||
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
|
['图片消费积分', exportSummary.imageConsume],
|
||||||
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
|
['视频消费积分', exportSummary.videoConsume],
|
||||||
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
|
['提词消费积分', exportSummary.textConsume],
|
||||||
['待核算流水数', exportSummary.providerCostPendingCount],
|
['视频分析积分', exportSummary.analysisConsume],
|
||||||
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
|
['总 Token', exportSummary.totalTokens],
|
||||||
], columns, rows: all,
|
['输入 Token', exportSummary.inputTokens],
|
||||||
|
['输出 Token', exportSummary.outputTokens],
|
||||||
|
],
|
||||||
|
columns: detailColumns,
|
||||||
|
rows: all,
|
||||||
});
|
});
|
||||||
message.success('Excel 已导出');
|
message.success('Excel 已导出');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '导出失败');
|
message.error(e?.message || '导出失败');
|
||||||
} finally { setExporting(false); setExportProgress(''); }
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
setExportProgress('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||||
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <div><Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.frontendUserKindLabel || '-'}</div></div> },
|
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||||
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
||||||
{ title: '流水类型', dataIndex: 'recordType', width: 105, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
||||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
|
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary">历史</Typography.Text>; } },
|
||||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
|
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||||
{ title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||||
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
||||||
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v, 2)}</Typography.Text> },
|
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||||
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) },
|
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
|
||||||
|
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||||||
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}>入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}</div></div> },
|
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}>入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}</div></div> },
|
||||||
{ title: '执行配置', key: 'engine', width: 240, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
|
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
|
||||||
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
||||||
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
|
||||||
{ title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) => <div>图 {n(r.attachmentImageCount)} / 视 {n(r.attachmentVideoCount)} / 音 {n(r.attachmentAudioCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}>合计 {n(r.attachmentTotalCount)}</div></div> },
|
|
||||||
{ title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) => <div>图 {n(r.generatedImageCount)} / 视 {n(r.generatedVideoCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}>请求 {n(r.requestedOutputCount)} / 实际 {n(r.generatedTotalCount)}</div></div> },
|
|
||||||
{ title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) => <div><Typography.Text strong>{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}</Typography.Text><div><Tag color={costStatusColor(r.providerCostStatus)}>{r.providerCostStatusLabel || '-'}</Tag>{r.providerCostIsEstimated && <Tag color="orange">估算</Tag>}</div></div> },
|
|
||||||
{ title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) => <div><div>{r.pricingVersionCode || '未锁价'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}</div></div> },
|
|
||||||
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => <Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>核查</Button> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
<Card bordered={false}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge, 2)}</div></div></Space></Card>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||||
<Card bordered={false}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume, 2)}</div></div></Space></Card>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||||
<Card bordered={false}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund, 2)}</div></div></Space></Card>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||||
<Card bordered={false}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
|
||||||
<Card size="small">图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume, 2)} 积分</Card>
|
|
||||||
<Card size="small">视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume, 2)} 积分</Card>
|
|
||||||
<Card size="small">提词消费:{n(summary.textConsume, 2)} 积分</Card>
|
|
||||||
<Card size="small">视频分析:{n(summary.analysisConsume, 2)} 积分</Card>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
|
||||||
<Card size="small">输入附件(图/视/音):{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)}</Card>
|
|
||||||
<Card size="small">实际产出(图/视):{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)}</Card>
|
|
||||||
<Card size="small">已核算成本:¥{n(summary.providerCostCalculatedTotal, 8)}</Card>
|
|
||||||
<Card size="small">估算成本:{n(summary.providerCostEstimatedCount)} 条 / ¥{n(summary.providerCostEstimatedTotal, 8)}</Card>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
|
||||||
<Card size="small">总 Token:{n(summary.totalTokens)}</Card>
|
|
||||||
<Card size="small">待核算:{n(summary.providerCostPendingCount)} 条</Card>
|
|
||||||
<Card size="small">异常成本:{n(summary.providerCostAbnormalCount)} 条</Card>
|
|
||||||
<Card size="small">成本参考合计:¥{n(summary.providerCostCombinedTotal, 8)}</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card size="small" bordered={false}>图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分</Card>
|
||||||
|
<Card size="small" bordered={false}>视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分</Card>
|
||||||
|
<Card size="small" bordered={false}>提词消费:{n(summary.textConsume)} 积分</Card>
|
||||||
|
<Card size="small" bordered={false}>视频分析:{n(summary.analysisConsume)} 积分</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||||
<Select value={teamFilter} onChange={v => { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} />
|
<Select
|
||||||
<Select value={recordType} onChange={v => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
value={teamFilter}
|
||||||
<Select value={creditSubject} onChange={v => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
<Select value={mediaType} onChange={v => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
style={{ width: 170 }}
|
||||||
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
options={[
|
||||||
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
|
{ value: '', label: '全部团队' },
|
||||||
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
<Select value={providerCostStatus} onChange={v => { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} />
|
]}
|
||||||
<Select value={hasAttachment} onChange={v => { setPage(1); setHasAttachment(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部附件' }, { value: 'true', label: '有附件' }, { value: 'false', label: '无附件' }]} />
|
/>
|
||||||
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={e => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear />
|
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||||
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
|
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||||
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
|
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||||
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
|
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||||
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
|
<Select value={chargeAction} onChange={(v) => { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} />
|
||||||
|
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
|
||||||
|
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
|
||||||
|
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
||||||
|
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
|
||||||
|
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space><Button onClick={handleReset}>重置</Button><Button icon={<ReloadOutlined />} onClick={load}>刷新</Button><Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button></Space>
|
|
||||||
</div>
|
</div>
|
||||||
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
||||||
<Table columns={columns} dataSource={records} rowKey="id" loading={loading} scroll={{ x: 3650 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showTotal: t => `共 ${t} 条` }} />
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 2160 }}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
<Drawer open={!!detail} width={860} title="积分流水财务核查" onClose={() => setDetail(null)}>
|
);
|
||||||
{detail && <>
|
|
||||||
<Descriptions bordered size="small" column={2}>
|
|
||||||
<Descriptions.Item label="流水ID" span={2}>{detail.id}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户">{detail.username || '-'} / {detail.phone || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="发生时间">{formatDate(detail.createdAt || '')}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="积分变动">{n(detail.amount, 2)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="余额">{n(detail.balanceAfter, 2)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="模型" span={2}>{detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="计价版本">{detail.pricingVersionCode || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="计价模式">{detail.pricingBillingModeLabel || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="计算器版本">{detail.pricingCalculatorVersion || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用量来源">{detail.pricingUsageSource || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="计价参考时间">{formatDate(detail.pricingReferenceAt || '')}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="价格生效区间">{formatDate(detail.pricingEffectiveFrom || '')} ~ {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="供应商成本"><b>{detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)}</b></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="核算状态"><Tag color={costStatusColor(detail.providerCostStatus)}>{detail.providerCostStatusLabel || '-'}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="最终核算时间">{formatDate(detail.providerCostFinalizedAt || '')}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="主供应商用量">{detail.providerUsagePrimary ? '是' : '否'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="附件统计">图 {detail.attachmentImageCount} / 视 {detail.attachmentVideoCount} / 音 {detail.attachmentAudioCount}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="产出统计">请求 {detail.requestedOutputCount} / 图 {detail.generatedImageCount} / 视 {detail.generatedVideoCount}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Token">入 {detail.inputTokens} / 出 {detail.outputTokens} / 总 {detail.totalTokens}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="业务归属">{detail.ownerType || '-'} / {detail.ownerId || '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
<Typography.Title level={5}>计价快照</Typography.Title><JsonBlock value={detail.pricingSnapshotJson} />
|
|
||||||
<Typography.Title level={5}>标准化用量</Typography.Title><JsonBlock value={detail.usageSnapshotJson} />
|
|
||||||
<Typography.Title level={5}>附件快照</Typography.Title><JsonBlock value={detail.attachmentSnapshotJson} />
|
|
||||||
<Typography.Title level={5}>生成快照</Typography.Title><JsonBlock value={detail.generationSnapshotJson} />
|
|
||||||
</>}
|
|
||||||
</Drawer>
|
|
||||||
</div>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminCreditRecords;
|
export default AdminCreditRecords;
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
|
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
|
||||||
ProjectOutlined,
|
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
|
||||||
PlayCircleOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
DollarOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getAdminStats, getSystemConfigs } from '../api';
|
import { getAdminStats, getSystemConfigs } from '../api';
|
||||||
import type { AdminStats, SystemConfig } from '../types';
|
import type { AdminStats, SystemConfig } from '../types';
|
||||||
@@ -17,10 +11,25 @@ import 'dayjs/locale/zh-cn';
|
|||||||
|
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
'ai_creation': 'AI创作',
|
||||||
|
'generation_record': '项目生成',
|
||||||
|
'hot_opening_replicate': '爆款开头复刻',
|
||||||
|
'shot_replicate': '拆镜复刻',
|
||||||
|
'payment': '支付充值',
|
||||||
|
'admin': '后台管理',
|
||||||
|
'team': '团队管理',
|
||||||
|
'unknown': '历史未知',
|
||||||
|
'other': '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899', '#14b8a6', '#f97316'];
|
||||||
|
|
||||||
const AdminDashboard: React.FC = () => {
|
const AdminDashboard: React.FC = () => {
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
|
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
|
||||||
|
const [activeRange, setActiveRange] = useState<string>('today');
|
||||||
const [siteName, setSiteName] = useState<string>('数据概览');
|
const [siteName, setSiteName] = useState<string>('数据概览');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -39,377 +48,377 @@ const AdminDashboard: React.FC = () => {
|
|||||||
const configs = await getSystemConfigs();
|
const configs = await getSystemConfigs();
|
||||||
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
|
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
|
||||||
if (siteConfig) {
|
if (siteConfig) {
|
||||||
const title = `${siteConfig.value} 管理后台`;
|
setSiteName(`${siteConfig.value} 管理后台`);
|
||||||
setSiteName(title);
|
document.title = `${siteConfig.value} 管理后台`;
|
||||||
document.title = title;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSiteName('数据概览');
|
|
||||||
document.title = '数据概览';
|
|
||||||
}
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { load(); loadSiteName(); }, []);
|
||||||
load();
|
|
||||||
loadSiteName();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToday = () => {
|
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
|
||||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
|
|
||||||
setStartDate(dates);
|
setStartDate(dates);
|
||||||
loadWithDates(dates);
|
if (range) setActiveRange(range);
|
||||||
};
|
|
||||||
|
|
||||||
const handleYesterday = () => {
|
|
||||||
const yesterday = dayjs().subtract(1, 'day');
|
|
||||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
|
|
||||||
setStartDate(dates);
|
|
||||||
loadWithDates(dates);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWeek = () => {
|
|
||||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
|
|
||||||
setStartDate(dates);
|
|
||||||
loadWithDates(dates);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMonth = () => {
|
|
||||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
|
|
||||||
setStartDate(dates);
|
|
||||||
loadWithDates(dates);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
|
|
||||||
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
|
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
|
||||||
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
|
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getAdminStats(start, end).then(data => {
|
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
|
||||||
setStats(data);
|
|
||||||
setLoading(false);
|
|
||||||
}).catch(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()], 'today');
|
||||||
|
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')], 'yesterday'); };
|
||||||
|
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()], 'week');
|
||||||
|
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()], 'month');
|
||||||
|
|
||||||
const handleDateChange = (dates: any) => {
|
const handleDateChange = (dates: any) => {
|
||||||
if (dates) {
|
if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
|
||||||
setStartDate([dates[0], dates[1]]);
|
|
||||||
loadWithDates([dates[0], dates[1]]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseStats = stats ? [
|
|
||||||
{
|
|
||||||
title: '用户数量',
|
|
||||||
value: stats.totalUsers,
|
|
||||||
lastPeriodValue: stats.lastPeriodUsers,
|
|
||||||
icon: <UserOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
prefix: '',
|
|
||||||
suffix: '位用户',
|
|
||||||
description: '平台注册用户总数'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '总项目数',
|
|
||||||
value: stats.totalProjects,
|
|
||||||
lastPeriodValue: stats.lastPeriodProjects,
|
|
||||||
icon: <ProjectOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
|
|
||||||
prefix: '',
|
|
||||||
suffix: '个项目',
|
|
||||||
description: '创建的项目总数'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '项目记录',
|
|
||||||
value: stats.totalRecords,
|
|
||||||
lastPeriodValue: stats.lastPeriodRecords,
|
|
||||||
icon: <FileTextOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
||||||
prefix: '',
|
|
||||||
suffix: '条记录',
|
|
||||||
description: '项目记录总数'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创作记录',
|
|
||||||
value: stats.totalGenerations,
|
|
||||||
lastPeriodValue: stats.lastPeriodGenerations,
|
|
||||||
icon: <PlayCircleOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
||||||
prefix: '',
|
|
||||||
suffix: '次创作',
|
|
||||||
description: 'AI创作记录总数'
|
|
||||||
},
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
const financeStats = stats ? [
|
|
||||||
{
|
|
||||||
title: '支付宝收入',
|
|
||||||
value: stats.todayAlipayRevenue,
|
|
||||||
icon: <WalletOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
|
||||||
prefix: '¥',
|
|
||||||
suffix: '',
|
|
||||||
description: '支付宝收款',
|
|
||||||
tag: '支付宝'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '微信收入',
|
|
||||||
value: stats.todayWechatRevenue,
|
|
||||||
icon: <DollarOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
|
||||||
prefix: '¥',
|
|
||||||
suffix: '',
|
|
||||||
description: '微信收款',
|
|
||||||
tag: '微信支付'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '总收入',
|
|
||||||
value: stats.totalRevenue,
|
|
||||||
lastPeriodValue: stats.lastPeriodRevenue,
|
|
||||||
icon: <ArrowUpOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
|
||||||
prefix: '¥',
|
|
||||||
suffix: '',
|
|
||||||
description: '平台总收入',
|
|
||||||
tag: '总收入'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '消耗积分',
|
|
||||||
value: stats.creditsConsumedToday,
|
|
||||||
lastPeriodValue: stats.lastPeriodCreditsConsumed,
|
|
||||||
icon: <DollarOutlined />,
|
|
||||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
|
||||||
prefix: '',
|
|
||||||
suffix: '积分',
|
|
||||||
description: '用户消耗积分',
|
|
||||||
tag: '积分消耗'
|
|
||||||
},
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
const StatCard: React.FC<{
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
lastPeriodValue?: number;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
gradient: string;
|
|
||||||
prefix?: string;
|
|
||||||
suffix?: string;
|
|
||||||
description?: string;
|
|
||||||
tag?: string;
|
|
||||||
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
|
|
||||||
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
|
|
||||||
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
|
|
||||||
: null;
|
|
||||||
const isPositive = change !== null && parseFloat(change) >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
bordered={false}
|
|
||||||
loading={loading}
|
|
||||||
hoverable
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
border: '1px solid rgba(0,0,0,0.04)',
|
|
||||||
background: '#ffffff',
|
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
padding: '16px 0'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
borderRadius: 14,
|
|
||||||
background: gradient,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 24,
|
|
||||||
color: '#fff',
|
|
||||||
flexShrink: 0,
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
|
|
||||||
}}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 4
|
|
||||||
}}>
|
|
||||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
|
|
||||||
{tag && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 10,
|
|
||||||
background: 'rgba(99,102,241,0.1)',
|
|
||||||
color: '#6366f1',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: '#1e293b',
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
marginBottom: 2
|
|
||||||
}}>
|
|
||||||
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
|
|
||||||
{description}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
{change !== null && lastPeriodValue !== undefined && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 8,
|
|
||||||
paddingTop: 8,
|
|
||||||
borderTop: '1px solid #f1f5f9',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8
|
|
||||||
}}>
|
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
|
|
||||||
上一周期: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
|
|
||||||
</Typography.Text>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: isPositive ? '#10b981' : '#ef4444',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2
|
|
||||||
}}>
|
|
||||||
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 0 }}>
|
<div>
|
||||||
<div style={{
|
{/* 日期筛选 */}
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
|
||||||
padding: '24px 24px 32px',
|
|
||||||
borderRadius: 0,
|
|
||||||
marginBottom: -24,
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -50,
|
|
||||||
right: -50,
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
background: 'rgba(255,255,255,0.1)',
|
|
||||||
borderRadius: '50%'
|
|
||||||
}} />
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: -30,
|
|
||||||
left: -30,
|
|
||||||
width: 150,
|
|
||||||
height: 150,
|
|
||||||
background: 'rgba(255,255,255,0.08)',
|
|
||||||
borderRadius: '50%'
|
|
||||||
}} />
|
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
|
|
||||||
{siteName}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
|
|
||||||
欢迎回来,查看平台运营数据统计
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 40 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}>今日</Button>
|
||||||
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
|
<Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}>昨日</Button>
|
||||||
size="small"
|
<Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}>本周</Button>
|
||||||
onClick={handleToday}
|
<Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}>本月</Button>
|
||||||
>
|
|
||||||
今日
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={handleYesterday}
|
|
||||||
>
|
|
||||||
昨日
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={handleWeek}
|
|
||||||
>
|
|
||||||
本周
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
onClick={handleMonth}
|
|
||||||
>
|
|
||||||
本月
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
|
||||||
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
|
<CalendarOutlined style={{ color: '#64748b' }} />
|
||||||
<DatePicker.RangePicker
|
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
|
||||||
value={startDate}
|
|
||||||
onChange={handleDateChange}
|
|
||||||
placeholder={['开始日期', '结束日期']}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
|
|
||||||
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}>核心数据</Typography.Text>
|
{/* 核心数据 */}
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}>平台基础运营指标</Typography.Text>
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 15 }}>核心数据</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[12, 12]}>
|
||||||
{baseStats.map((s) => (
|
{[
|
||||||
<Col xs={12} sm={8} lg={6} key={s.title}>
|
{ title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
|
||||||
<StatCard {...s} />
|
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
|
||||||
|
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
|
||||||
|
].map(s => (
|
||||||
|
<Col xs={12} sm={8} md={4} key={s.title}>
|
||||||
|
<CompactStatCard {...s} loading={loading} />
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
{/* 图表区域 */}
|
||||||
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}>财务统计</Typography.Text>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}>收入与消耗数据</Typography.Text>
|
<Typography.Text strong style={{ fontSize: 15 }}>数据统计</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{financeStats.map((s) => (
|
<Col xs={24} lg={12}>
|
||||||
<Col xs={12} sm={8} lg={6} key={s.title}>
|
<ChartCard title="每日积分消耗趋势" loading={loading}>
|
||||||
<StatCard {...s} />
|
<LineChart data={stats?.dailyCreditsByModule || []} />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<ChartCard title="各模块积分占比" loading={loading}>
|
||||||
|
<ModulePie data={stats?.periodCreditsByModule || []} />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<ChartCard title="团队积分消耗排行" loading={loading}>
|
||||||
|
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<ChartCard title="模型使用次数" loading={loading}>
|
||||||
|
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频参数分布 */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 15 }}>视频参数分布</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<ChartCard title="分辨率分布" loading={loading}>
|
||||||
|
<PieBarChart data={stats?.videoResolutionUsage || []} />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<ChartCard title="画面比例分布" loading={loading}>
|
||||||
|
<PieBarChart data={stats?.videoRatioUsage || []} />
|
||||||
|
</ChartCard>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<ChartCard title="时长分布" loading={loading}>
|
||||||
|
<PieBarChart data={stats?.videoDurationUsage || []} />
|
||||||
|
</ChartCard>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 图表卡片 ──
|
||||||
|
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
|
||||||
|
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
|
||||||
|
styles={{ body: { padding: '16px' } }}>
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={loading}>{children}</Spin>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 核心数据小卡片 ──
|
||||||
|
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
|
||||||
|
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
|
||||||
|
styles={{ body: { padding: '12px 14px' } }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 折线图(按日期+模块,固定展示选中日期往前7天)──
|
||||||
|
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
|
||||||
|
// 以数据中最新日期为基准,往前推 7 天;不足 7 天按实际天数
|
||||||
|
if (!data.length) return <EmptyChart />;
|
||||||
|
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
|
||||||
|
const maxDate = sortedDates[sortedDates.length - 1];
|
||||||
|
// 生成 [maxDate-6, maxDate] 共 7 天
|
||||||
|
const baseDayjs = dayjs(maxDate);
|
||||||
|
const sevenDays: string[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) sevenDays.push(baseDayjs.subtract(i, 'day').format('YYYY-MM-DD'));
|
||||||
|
|
||||||
|
const dateMap = new Map<string, number>();
|
||||||
|
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); });
|
||||||
|
const maxVal = Math.max(...sevenDays.map(d => dateMap.get(d) || 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||||
|
{sevenDays.map(d => {
|
||||||
|
const val = dateMap.get(d) || 0;
|
||||||
|
const pct = (val / maxVal) * 100;
|
||||||
|
return (
|
||||||
|
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||||
|
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||||
|
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
|
||||||
|
if (tip) { tip.style.opacity = '1'; }
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||||
|
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||||
|
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
|
||||||
|
if (tip) { tip.style.opacity = '0'; }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
|
||||||
|
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
|
||||||
|
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||||
|
{sevenDays.map(d => (
|
||||||
|
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 模块积分占比(饼图)──
|
||||||
|
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
|
||||||
|
if (!data.length) return <EmptyChart />;
|
||||||
|
const moduleMap = new Map<string, number>();
|
||||||
|
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
|
||||||
|
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
|
||||||
|
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
|
||||||
|
|
||||||
|
// 计算饼图扇形路径
|
||||||
|
const size = 160;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const r = 68;
|
||||||
|
let cumAngle = -90; // 从顶部开始
|
||||||
|
|
||||||
|
const slices = modules.map(([mod, val], i) => {
|
||||||
|
const pct = val / total;
|
||||||
|
const angle = pct * 360;
|
||||||
|
const startAngle = cumAngle;
|
||||||
|
cumAngle += angle;
|
||||||
|
const endAngle = cumAngle;
|
||||||
|
const startRad = (startAngle * Math.PI) / 180;
|
||||||
|
const endRad = (endAngle * Math.PI) / 180;
|
||||||
|
const largeArc = angle > 180 ? 1 : 0;
|
||||||
|
const x1 = cx + r * Math.cos(startRad);
|
||||||
|
const y1 = cy + r * Math.sin(startRad);
|
||||||
|
const x2 = cx + r * Math.cos(endRad);
|
||||||
|
const y2 = cy + r * Math.sin(endRad);
|
||||||
|
const d = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
|
||||||
|
return { d, color: COLORS[i % COLORS.length], label: MODULE_LABELS[mod] || mod, val, pct };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
|
||||||
|
<defs>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
|
||||||
|
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
|
||||||
|
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
(e.target as SVGPathElement).style.transform = 'scale(1)';
|
||||||
|
(e.target as SVGPathElement).style.filter = 'none';
|
||||||
|
}}
|
||||||
|
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle cx={cx} cy={cy} r={36} fill="#fff" />
|
||||||
|
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b">总计</text>
|
||||||
|
<text x={cx} y={cy + 12} textAnchor="middle" fontSize={13} fontWeight={700} fill="#1e293b">{total.toFixed(0)}</text>
|
||||||
|
</svg>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
|
||||||
|
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 横向柱状图(团队/模型)──
|
||||||
|
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
|
||||||
|
if (!data.length) return <EmptyChart />;
|
||||||
|
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const label = d.teamName || d.modelName || '-';
|
||||||
|
const val = (d as any)[valueKey] || 0;
|
||||||
|
const pct = (val / maxVal) * 100;
|
||||||
|
const c1 = COLORS[i % COLORS.length];
|
||||||
|
const c2 = COLORS[(i + 1) % COLORS.length];
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 视频参数分布(紧凑饼图+列表)──
|
||||||
|
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
|
||||||
|
if (!data.length) return <EmptyChart />;
|
||||||
|
// 按模型分组
|
||||||
|
const modelMap = new Map<string, { label: string; count: number }[]>();
|
||||||
|
data.forEach(d => {
|
||||||
|
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
|
||||||
|
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
|
||||||
|
});
|
||||||
|
const models = Array.from(modelMap.entries());
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, overflowY: 'auto' }}>
|
||||||
|
{models.map(([model, items], mi) => {
|
||||||
|
const modelTotal = items.reduce((s, it) => s + it.count, 0);
|
||||||
|
return (
|
||||||
|
<div key={model} style={{ marginBottom: 12 }}>
|
||||||
|
{/* 模型名称 + 总数 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#94a2b3' }}>共 {modelTotal} 条 ({((modelTotal / total) * 100).toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
{/* 各参数 */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
flex: '0 0 calc(50% - 2px)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #f1f5f9',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
|
||||||
|
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyChart: React.FC = () => (
|
||||||
|
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default AdminDashboard;
|
export default AdminDashboard;
|
||||||
|
|||||||
@@ -29,9 +29,16 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getAdminGenerationAiTasks } from '../api';
|
import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api';
|
||||||
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
|
import type {
|
||||||
|
GenerationAiImageEngine,
|
||||||
|
GenerationAIMediaReference,
|
||||||
|
GenerationAITaskOut,
|
||||||
|
GenerationAiVideoEngine,
|
||||||
|
} from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
|
||||||
|
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -66,27 +73,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
|
|||||||
references: {},
|
references: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
|
||||||
pending: { color: 'default', text: '待处理', icon: <ClockCircleOutlined /> },
|
|
||||||
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
|
|
||||||
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
|
||||||
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PIPELINE_STAGE_MAP: Record<string, string> = {
|
|
||||||
timeout: '任务超时',
|
|
||||||
queued: '已入队',
|
|
||||||
preparing: '准备中',
|
|
||||||
creating_provider_task: '创建任务中',
|
|
||||||
waiting_remote: '等待生成',
|
|
||||||
result_ready: '结果就绪',
|
|
||||||
downloading: '下载中',
|
|
||||||
done: '完成',
|
|
||||||
download_failed: '下载失败',
|
|
||||||
polling: '轮询中',
|
|
||||||
failed: '失败',
|
|
||||||
};
|
|
||||||
|
|
||||||
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||||
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
||||||
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
||||||
@@ -237,6 +223,9 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [engineListLoading, setEngineListLoading] = useState(false);
|
||||||
|
const [imageEngines, setImageEngines] = useState<GenerationAiImageEngine[]>([]);
|
||||||
|
const [videoEngines, setVideoEngines] = useState<GenerationAiVideoEngine[]>([]);
|
||||||
|
|
||||||
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
|
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
|
||||||
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
||||||
@@ -277,6 +266,64 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
load();
|
load();
|
||||||
}, [load, reloadKey]);
|
}, [load, reloadKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadEngineOptions = async () => {
|
||||||
|
setEngineListLoading(true);
|
||||||
|
try {
|
||||||
|
const [images, videos] = await Promise.all([
|
||||||
|
getImageEngines({ includeDeleted: true }),
|
||||||
|
getVideoEngines({ includeDeleted: true }),
|
||||||
|
]);
|
||||||
|
if (!cancelled) {
|
||||||
|
setImageEngines(images || []);
|
||||||
|
setVideoEngines(videos || []);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
message.error(error?.message || '加载模型引擎列表失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setEngineListLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadEngineOptions();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const engineOptions = useMemo(() => {
|
||||||
|
const toOption = (
|
||||||
|
engine: GenerationAiImageEngine | GenerationAiVideoEngine,
|
||||||
|
type: 'image' | 'video',
|
||||||
|
) => {
|
||||||
|
const deleted = Boolean(engine.deletedAt);
|
||||||
|
const typeText = type === 'image' ? '图片' : '视频';
|
||||||
|
const deletedText = deleted ? '[已删除]' : '';
|
||||||
|
const detailText = [engine.name, engine.modelName, engine.provider, engine.id]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
const label = `[${typeText}]${deletedText} ${detailText}`;
|
||||||
|
return {
|
||||||
|
value: engine.id,
|
||||||
|
label,
|
||||||
|
searchText: [engine.id, engine.name, engine.modelName, engine.provider, typeText, deleted ? '已删除' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = [
|
||||||
|
...imageEngines.map((engine) => toOption(engine, 'image')),
|
||||||
|
...videoEngines.map((engine) => toOption(engine, 'video')),
|
||||||
|
];
|
||||||
|
return Array.from(new Map(merged.map((option) => [option.value, option])).values());
|
||||||
|
}, [imageEngines, videoEngines]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
setResourceState(EMPTY_RESOURCE_STATE);
|
setResourceState(EMPTY_RESOURCE_STATE);
|
||||||
@@ -425,6 +472,25 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '生成数量', key: 'generationCount', width: 150,
|
||||||
|
render: (_: any, r: GenerationAITaskOut) => {
|
||||||
|
const count = Math.max(1, Number(r.generationCount || 1));
|
||||||
|
if (count === 1) return <Tag>1份</Tag>;
|
||||||
|
const children = r.childItems || [];
|
||||||
|
const completed = children.filter((item) => resolveGenerationUiState(item).isSuccess).length;
|
||||||
|
const failed = children.filter((item) => resolveGenerationUiState(item).isFailure).length;
|
||||||
|
const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length;
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<Tag color="purple">{count}份</Tag>
|
||||||
|
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
|
||||||
|
{completed}完成{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '引擎', key: 'engine', width: 160,
|
title: '引擎', key: 'engine', width: 160,
|
||||||
render: (_: any, r: GenerationAITaskOut) => {
|
render: (_: any, r: GenerationAITaskOut) => {
|
||||||
@@ -475,7 +541,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '结果', key: 'result', width: 90,
|
title: '结果', key: 'result', width: 90,
|
||||||
render: (_: any, r: GenerationAITaskOut) => {
|
render: (_: any, r: GenerationAITaskOut) => {
|
||||||
if (r.status !== 'completed') {
|
if (!resolveGenerationUiState(r).isSuccess) {
|
||||||
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
|
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
|
||||||
}
|
}
|
||||||
if (r.genType === 'video' && r.videoUrl) {
|
if (r.genType === 'video' && r.videoUrl) {
|
||||||
@@ -551,13 +617,14 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'status', width: 100,
|
title: '状态', dataIndex: 'status', width: 100,
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
|
const state = resolveGenerationUiState({ status: v });
|
||||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
|
||||||
|
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '阶段', dataIndex: 'pipelineStage', width: 120,
|
title: '阶段', dataIndex: 'pipelineStage', width: 120,
|
||||||
render: (v: string) => <Tag color="blue">{PIPELINE_STAGE_MAP[v] || v || '-'}</Tag>,
|
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间', key: 'time', width: 170,
|
title: '时间', key: 'time', width: 170,
|
||||||
@@ -579,10 +646,10 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
], [handleOpenPreview]);
|
], [handleOpenPreview]);
|
||||||
|
|
||||||
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
||||||
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
|
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
|
||||||
|
|
||||||
const renderResultImage = () => {
|
const renderResultImage = () => {
|
||||||
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
|
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||||
|
|
||||||
if (!preview.imageUrl) {
|
if (!preview.imageUrl) {
|
||||||
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
||||||
@@ -722,7 +789,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResultVideo = () => {
|
const renderResultVideo = () => {
|
||||||
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
|
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||||
|
|
||||||
if (!preview.videoUrl) {
|
if (!preview.videoUrl) {
|
||||||
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
||||||
@@ -947,21 +1014,17 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
|
loading={engineListLoading}
|
||||||
placeholder="引擎筛选"
|
placeholder="引擎筛选"
|
||||||
value={filterEngineId || undefined}
|
value={filterEngineId || undefined}
|
||||||
style={{ width: 180 }}
|
style={{ width: 260 }}
|
||||||
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
|
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
|
||||||
optionFilterProp="label"
|
filterOption={(input, option: any) =>
|
||||||
options={Array.from(
|
String(option?.searchText || option?.label || '')
|
||||||
new Map(
|
.toLowerCase()
|
||||||
records
|
.includes(input.trim().toLowerCase())
|
||||||
.filter((r) => r.engineId)
|
}
|
||||||
.map((r) => [r.engineId, {
|
options={engineOptions}
|
||||||
value: r.engineId,
|
|
||||||
label: getEngineName(r.engineSnapshot as any) || r.engineId,
|
|
||||||
}]),
|
|
||||||
).values(),
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<RangePicker
|
<RangePicker
|
||||||
value={createdRange}
|
value={createdRange}
|
||||||
@@ -1036,8 +1099,8 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
||||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
|
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
|
||||||
{preview.pipelineStage ? <Tag color="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null}
|
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
|
||||||
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
|
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1045,6 +1108,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
|
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
|
||||||
<InfoItem label="用户ID" value={preview.userId || '-'} />
|
<InfoItem label="用户ID" value={preview.userId || '-'} />
|
||||||
<InfoItem label="任务ID" value={preview.id} />
|
<InfoItem label="任务ID" value={preview.id} />
|
||||||
|
<InfoItem label="生成数量" value={`${preview.generationCount || 1} 份`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1122,40 +1186,18 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{preview.status === 'completed' ? (
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
|
生成资源(共 {preview.generationCount || 1} 份)
|
||||||
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{preview.genType === 'video' && preview.videoUrl ? (
|
<GenerationTaskResourceGrid
|
||||||
<Button
|
task={preview}
|
||||||
size="small"
|
resolveUrl={apiUrl}
|
||||||
type="link"
|
onPreview={handlePreviewResource}
|
||||||
icon={<PlayCircleOutlined />}
|
/>
|
||||||
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
弹窗播放
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{preview.genType === 'image' && preview.imageUrl ? (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="link"
|
|
||||||
icon={<FileImageOutlined />}
|
|
||||||
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
弹窗查看
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{preview.status === 'failed' && preview.errorMessage ? (
|
{previewStatusConfig?.isFailure && preview.errorMessage ? (
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
ExclamationCircleOutlined,
|
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
|
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
|
||||||
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
|
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
|
||||||
|
|
||||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
|
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
|
||||||
@@ -50,14 +50,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
|
|||||||
references: {},
|
references: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
|
||||||
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
|
|
||||||
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
|
|
||||||
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
|
|
||||||
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
|
||||||
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
|
||||||
};
|
|
||||||
|
|
||||||
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||||
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
||||||
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
||||||
@@ -194,13 +186,14 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
const [pageSize] = useState(20);
|
const [pageSize] = useState(20);
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||||
const [filterUserId, setFilterUserId] = useState<string>('');
|
const [filterUserId, setFilterUserId] = useState<string>('');
|
||||||
|
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
||||||
|
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
|
||||||
|
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
||||||
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
||||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
|
||||||
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string; image_size: string } | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -208,6 +201,8 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
const res = await getAdminGenerationRecords({
|
const res = await getAdminGenerationRecords({
|
||||||
userId: filterUserId.trim() || undefined,
|
userId: filterUserId.trim() || undefined,
|
||||||
status: filterStatus || undefined,
|
status: filterStatus || undefined,
|
||||||
|
engineId: filterEngineId || undefined,
|
||||||
|
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
@@ -240,6 +235,10 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
imageTokensUsed: item.imageTokensUsed || 0,
|
imageTokensUsed: item.imageTokensUsed || 0,
|
||||||
imageProportion: item.imageProportion,
|
imageProportion: item.imageProportion,
|
||||||
imagePx: item.imagePx,
|
imagePx: item.imagePx,
|
||||||
|
engineId: item.engineId,
|
||||||
|
engineName: item.engineName,
|
||||||
|
engineSnapshot: item.engineSnapshot,
|
||||||
|
includeMediaReferences: item.includeMediaReferences,
|
||||||
})));
|
})));
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -247,12 +246,32 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filterStatus, filterUserId, page, pageSize]);
|
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load, reloadKey]);
|
}, [load, reloadKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
getImageEngines({ includeDeleted: true }),
|
||||||
|
getVideoEngines({ includeDeleted: true }),
|
||||||
|
])
|
||||||
|
.then(([imageEngines, videoEngines]) => {
|
||||||
|
const items = [...(imageEngines || []), ...(videoEngines || [])];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((acc, item: any) => {
|
||||||
|
const id = String(item?.id || '');
|
||||||
|
if (!id || seen.has(id)) return acc;
|
||||||
|
seen.add(id);
|
||||||
|
const deletedSuffix = item?.deletedAt ? '(已删除)' : '';
|
||||||
|
acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` });
|
||||||
|
return acc;
|
||||||
|
}, []));
|
||||||
|
})
|
||||||
|
.catch(() => setEngineOptions([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
setResourceState(EMPTY_RESOURCE_STATE);
|
setResourceState(EMPTY_RESOURCE_STATE);
|
||||||
@@ -349,33 +368,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
|
|
||||||
setUpdating(recordId);
|
|
||||||
try {
|
|
||||||
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
|
|
||||||
message.success('状态已更新');
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '更新失败');
|
|
||||||
} finally {
|
|
||||||
setUpdating(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
if (!genModal) return;
|
|
||||||
setUpdating(genModal.record.id);
|
|
||||||
try {
|
|
||||||
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution, genModal.image_size);
|
|
||||||
message.success(`已提交${genModal.record.genType === 'video' ? '视频' : '图片'}生成`);
|
|
||||||
setGenModal(null);
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '生成失败');
|
|
||||||
} finally {
|
|
||||||
setUpdating(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
@@ -416,21 +409,27 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
title: '参数', key: 'params', width: 160,
|
title: '参数', key: 'params', width: 160,
|
||||||
render: (_: any, r: AdminGenerationRecord) => (
|
render: (_: any, r: AdminGenerationRecord) => (
|
||||||
r.genType === 'video' ? (
|
r.genType === 'video' ? (
|
||||||
r.duration || r.aspectRatio || r.resolution ? (
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
||||||
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
||||||
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
||||||
|
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default">待配置</Tag> : null}
|
||||||
|
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||||
|
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||||
|
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||||
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
) : <Tag color="default">待配置</Tag>
|
|
||||||
) : (
|
) : (
|
||||||
r.imageSize || r.imageProportion || r.imagePx ? (
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
||||||
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
||||||
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
||||||
|
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default">待配置</Tag> : null}
|
||||||
|
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||||
|
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||||
|
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||||
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
) : <Tag color="default">待配置</Tag>
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -453,10 +452,15 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'status', width: 90,
|
title: '状态', dataIndex: 'status', width: 90,
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
|
const state = resolveGenerationUiState({ status: v });
|
||||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
|
||||||
|
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '阶段', dataIndex: 'pipelineStage', width: 150,
|
||||||
|
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '时间', key: 'time', width: 150,
|
title: '时间', key: 'time', width: 150,
|
||||||
render: (_: any, r: AdminGenerationRecord) => (
|
render: (_: any, r: AdminGenerationRecord) => (
|
||||||
@@ -467,78 +471,20 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
|
||||||
render: (_: any, r: AdminGenerationRecord) => (
|
render: (_: any, r: AdminGenerationRecord) => (
|
||||||
<Space size={4} wrap>
|
|
||||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
{r.status === 'generating' ? (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
loading={updating === r.id}
|
|
||||||
onClick={() => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认操作',
|
|
||||||
icon: <ExclamationCircleOutlined />,
|
|
||||||
content: '确定将此记录标记为失败?',
|
|
||||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标记失败
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{r.status === 'failed' ? (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
danger
|
|
||||||
loading={updating === r.id}
|
|
||||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
|
||||||
>
|
|
||||||
重试生成
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{r.status === 'prompt_optimized' ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
loading={updating === r.id}
|
|
||||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
|
||||||
style={{ background: '#6366f1', border: 'none' }}
|
|
||||||
>
|
|
||||||
生成{r.genType === 'video' ? '视频' : '图片'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
loading={updating === r.id}
|
|
||||||
onClick={() => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认操作',
|
|
||||||
icon: <ExclamationCircleOutlined />,
|
|
||||||
content: '确定将此记录标记为失败?',
|
|
||||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标记失败
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
], [handleOpenPreview, updating]);
|
], [handleOpenPreview]);
|
||||||
|
|
||||||
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
||||||
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
|
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
|
||||||
|
|
||||||
const renderResultImage = () => {
|
const renderResultImage = () => {
|
||||||
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
|
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||||
|
|
||||||
if (!preview.imageUrl) {
|
if (!preview.imageUrl) {
|
||||||
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
||||||
@@ -681,7 +627,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResultVideo = () => {
|
const renderResultVideo = () => {
|
||||||
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
|
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||||
|
|
||||||
if (!preview.videoUrl) {
|
if (!preview.videoUrl) {
|
||||||
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
||||||
@@ -894,6 +840,27 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
{ value: 'failed', label: '失败' },
|
{ value: 'failed', label: '失败' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="引擎筛选"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
value={filterEngineId || undefined}
|
||||||
|
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
|
||||||
|
options={engineOptions}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="附件状态"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 130 }}
|
||||||
|
value={filterIncludeMedia || undefined}
|
||||||
|
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: '携带附件' },
|
||||||
|
{ value: 'false', label: '不携带附件' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="用户ID搜索"
|
placeholder="用户ID搜索"
|
||||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
@@ -956,7 +923,9 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>类型 / 状态</Typography.Text>
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>类型 / 状态</Typography.Text>
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
||||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
|
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
|
||||||
|
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
|
||||||
|
{preview.videoUpscaleEnabled ? <Tag color="purple">已启用超分</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1035,7 +1004,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
|
|
||||||
{renderReferences()}
|
{renderReferences()}
|
||||||
|
|
||||||
{preview.status === 'completed' ? (
|
{resolveGenerationUiState(preview).isSuccess ? (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||||
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
||||||
@@ -1045,7 +1014,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{preview.status === 'failed' && preview.errorMessage ? (
|
{resolveGenerationUiState(preview).isFailure && preview.errorMessage ? (
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -1062,73 +1031,6 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Generate modal */}
|
|
||||||
<Modal
|
|
||||||
title={(
|
|
||||||
<Space>
|
|
||||||
{genModal && (genModal.record.genType === 'video' ? <PlayCircleOutlined /> : <FileImageOutlined />)}
|
|
||||||
{genModal && (genModal.record.genType === 'video' ? '生成视频' : '生成图片')}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
open={!!genModal}
|
|
||||||
onCancel={() => setGenModal(null)}
|
|
||||||
onOk={handleGenerate}
|
|
||||||
okText="提交生成"
|
|
||||||
cancelText="取消"
|
|
||||||
confirmLoading={genModal ? updating === genModal.record.id : false}
|
|
||||||
width={420}
|
|
||||||
>
|
|
||||||
{genModal ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
|
||||||
{genModal.record.genType === 'video' ? (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
|
||||||
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>画面比例</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={genModal.ratio}
|
|
||||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, ratio: v } : null))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map((r) => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={genModal.resolution}
|
|
||||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, resolution: v } : null))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={['480p', '720p', '1080p'].map((r) => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>尺寸</Typography.Text>
|
|
||||||
<Typography.Text strong>{genModal.record.imagePx || '-'}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>比例</Typography.Text>
|
|
||||||
<Typography.Text strong>{genModal.record.imageProportion || '-'}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
|
||||||
<Select
|
|
||||||
value={genModal.image_size}
|
|
||||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, image_size: v } : null))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={['2K', '4K'].map((r) => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
import { EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getAdminHotOpeningTasks } from '../api';
|
import { getAdminHotOpeningTasks } from '../api';
|
||||||
import type { HotOpeningTaskListItemOut } from '../types';
|
import type { HotOpeningTaskListItemOut } from '../types';
|
||||||
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space>
|
||||||
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
|
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
<div>
|
<Typography.Text strong style={{ fontSize: 16 }}>爆款开头复刻</Typography.Text>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>爆款开头复刻</Typography.Title>
|
<Tag color="purple">{total} 条记录</Tag>
|
||||||
<Typography.Text type="secondary">只读排查页面:查看任务列表、素材、步骤详情和生成结果。</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
@@ -208,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
|
||||||
|
{ title: '流程版本', dataIndex: 'flowVersion', width: 100, render: (v: string) => <Tag color={v === 'v2' ? 'blue' : 'default'}>{String(v || 'v1').toUpperCase()}</Tag> },
|
||||||
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
|
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
|
||||||
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||||
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||||
@@ -219,14 +219,12 @@ const AdminHotOpeningReplications: React.FC = () => {
|
|||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}>详情</Button>
|
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}>详情</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||||
@@ -21,6 +21,11 @@ interface ImageEngine {
|
|||||||
generateUrl: string;
|
generateUrl: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
multiGenerationEnabled: boolean;
|
||||||
|
maxGenerationCount: number;
|
||||||
|
multiImageMaxImages: number;
|
||||||
|
maxReferenceImageCount: number;
|
||||||
|
outputFormat: '' | 'png' | 'jpeg';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonArray(val: unknown): any[] {
|
function parseJsonArray(val: unknown): any[] {
|
||||||
@@ -80,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -126,6 +132,11 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
generate_url: values.generateUrl || '',
|
generate_url: values.generateUrl || '',
|
||||||
is_active: values.isActive ?? true,
|
is_active: values.isActive ?? true,
|
||||||
priority: values.priority ?? 0,
|
priority: values.priority ?? 0,
|
||||||
|
multi_generation_enabled: values.multiGenerationEnabled ?? false,
|
||||||
|
max_generation_count: values.maxGenerationCount ?? 1,
|
||||||
|
multi_image_max_images: values.multiImageMaxImages ?? 15,
|
||||||
|
max_reference_image_count: values.maxReferenceImageCount ?? 14,
|
||||||
|
output_format: values.outputFormat ?? '',
|
||||||
};
|
};
|
||||||
if (modal.engine) {
|
if (modal.engine) {
|
||||||
await saveImageEngine({ id: modal.engine.id, ...payload });
|
await saveImageEngine({ id: modal.engine.id, ...payload });
|
||||||
@@ -168,6 +179,8 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
|
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
|
||||||
|
maxReferenceImageCount: 14, outputFormat: '',
|
||||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||||
defaultSize: '2K',
|
defaultSize: '2K',
|
||||||
maxImageCount: 0,
|
maxImageCount: 0,
|
||||||
@@ -232,6 +245,18 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
||||||
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
|
||||||
|
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
|
||||||
|
render: (v: number, r: ImageEngine) => (
|
||||||
|
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
|
||||||
|
最多 {r.multiGenerationEnabled ? (v || 1) : 1} 份
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'isActive', width: 80,
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||||
@@ -350,6 +375,33 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
<Form.Item name="generateUrl" label="生成接口地址">
|
<Form.Item name="generateUrl" label="生成接口地址">
|
||||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong>多份生成能力</Typography.Text>
|
||||||
|
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
|
||||||
|
管理后台只控制是否允许客户端选择多份及最大数量。客户端本次选择 2-5 份时,后端只调用一次火山同步组图 API;失败绝不降级成多次单图请求。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||||
|
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outputFormat" label="供应商输出格式">
|
||||||
|
<Select size="large" options={[
|
||||||
|
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
|
||||||
|
{ value: 'png', label: 'PNG' },
|
||||||
|
{ value: 'jpeg', label: 'JPEG' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="priority" label="优先级">
|
<Form.Item name="priority" label="优先级">
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
|
|||||||
@@ -233,15 +233,15 @@ const AdminLayout: React.FC = () => {
|
|||||||
width: 42,
|
width: 42,
|
||||||
height: 42,
|
height: 42,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
|
background: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{siteLogo ? (
|
{siteLogo ? (
|
||||||
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
|
<img src={siteLogo} alt="logo" style={{ width: 36, height: 36, objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
|
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Button, Card, Drawer, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
|
||||||
import { CopyOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import {
|
|
||||||
createModelPricingRule,
|
|
||||||
disableModelPricingRule,
|
|
||||||
getModelPricingRules,
|
|
||||||
publishModelPricingRule,
|
|
||||||
updateModelPricingRule,
|
|
||||||
} from '../api';
|
|
||||||
import type { ModelPricingRule, ModelPricingRulePayload } from '../types';
|
|
||||||
import PricingRuleForm from '../components/modelPricing/PricingRuleForm';
|
|
||||||
import PricingRulePreview from '../components/modelPricing/PricingRulePreview';
|
|
||||||
import { formatDate } from '../utils/formatDate';
|
|
||||||
|
|
||||||
const statusMap: Record<string, { color: string; text: string }> = {
|
|
||||||
draft: { color: 'default', text: '草稿' },
|
|
||||||
published: { color: 'green', text: '已发布' },
|
|
||||||
disabled: { color: 'red', text: '已停用' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const modeMap: Record<string, string> = {
|
|
||||||
text_token_tiered: '文本分档 Token',
|
|
||||||
image_per_output: '按成功输出图片',
|
|
||||||
image_input_output_tiered: '输入图 + 输出像素',
|
|
||||||
video_token_rate: '视频 Token',
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminModelPricingRules: React.FC = () => {
|
|
||||||
const [rows, setRows] = useState<ModelPricingRule[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(50);
|
|
||||||
const [modelName, setModelName] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [category, setCategory] = useState('');
|
|
||||||
const [editing, setEditing] = useState<ModelPricingRule | null>(null);
|
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
|
||||||
const [detail, setDetail] = useState<ModelPricingRule | null>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await getModelPricingRules({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
modelName: modelName || undefined,
|
|
||||||
publishStatus: status || undefined,
|
|
||||||
modelCategory: category || undefined,
|
|
||||||
});
|
|
||||||
setRows(res.items || []);
|
|
||||||
setTotal(res.total || 0);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载模型计价规则失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [page, pageSize, modelName, status, category]);
|
|
||||||
|
|
||||||
const submit = async (payload: ModelPricingRulePayload) => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (editing?.id && editing.publishStatus === 'draft') {
|
|
||||||
await updateModelPricingRule(editing.id, payload);
|
|
||||||
} else {
|
|
||||||
await createModelPricingRule(payload);
|
|
||||||
}
|
|
||||||
message.success('价格草稿已保存');
|
|
||||||
setFormOpen(false);
|
|
||||||
setEditing(null);
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cloneRule = (rule: ModelPricingRule) => {
|
|
||||||
setEditing({
|
|
||||||
...rule,
|
|
||||||
id: '',
|
|
||||||
publishStatus: 'draft',
|
|
||||||
versionCode: `${rule.versionCode}_copy_${dayjs().format('YYYYMMDDHHmm')}`,
|
|
||||||
effectiveFrom: dayjs().add(1, 'minute').toISOString(),
|
|
||||||
effectiveTo: null,
|
|
||||||
referencedCount: 0,
|
|
||||||
});
|
|
||||||
setFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '模型', dataIndex: 'modelName', width: 280, fixed: 'left' as const, render: (v: string, r: ModelPricingRule) => <div><Typography.Text strong>{v}</Typography.Text><div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider} / {r.modelCategory}</div></div> },
|
|
||||||
{ title: '价格版本', dataIndex: 'versionCode', width: 180 },
|
|
||||||
{ title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) => <div>{modeMap[r.billingMode] || r.billingMode}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.calculatorVersion}</div></div> },
|
|
||||||
{ title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) => <div>{formatDate(r.effectiveFrom)}<div style={{ color: '#94a3b8', fontSize: 12 }}>至 {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}</div></div> },
|
|
||||||
{ title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => <Tag color={(statusMap[v] || {}).color}>{(statusMap[v] || {}).text || v}</Tag> },
|
|
||||||
{ title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) => <div>{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}…` : '-'}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.referencedCount || 0} 条引用</div></div> },
|
|
||||||
{ title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' },
|
|
||||||
{ title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => <Space>
|
|
||||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>详情/试算</Button>
|
|
||||||
{r.publishStatus === 'draft' && <Button size="small" onClick={() => { setEditing(r); setFormOpen(true); }}>编辑</Button>}
|
|
||||||
<Button size="small" icon={<CopyOutlined />} onClick={() => cloneRule(r)}>克隆新版本</Button>
|
|
||||||
{r.publishStatus === 'draft' && <Popconfirm title="发布后价格正文不可修改,确认发布?" onConfirm={async () => { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}><Button size="small" type="primary">发布</Button></Popconfirm>}
|
|
||||||
{r.publishStatus === 'published' && <Popconfirm title="停用后不再匹配新消费,历史快照不受影响。确认?" onConfirm={async () => { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}><Button size="small" danger>停用</Button></Popconfirm>}
|
|
||||||
</Space> },
|
|
||||||
];
|
|
||||||
|
|
||||||
return <div>
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
||||||
<Space wrap>
|
|
||||||
<Input allowClear placeholder="模型名称" value={modelName} onChange={e => { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} />
|
|
||||||
<Select value={category} onChange={v => { setPage(1); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
|
|
||||||
<Select value={status} onChange={v => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
|
|
||||||
</Space>
|
|
||||||
<Space>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); setFormOpen(true); }}>新增价格版本</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1500 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal open={formOpen} title={editing?.id ? '编辑价格草稿' : editing ? '克隆价格版本' : '新增价格版本'} width={1100} footer={null} destroyOnClose onCancel={() => { setFormOpen(false); setEditing(null); }}>
|
|
||||||
<PricingRuleForm initial={editing} loading={saving} onSubmit={submit} onCancel={() => { setFormOpen(false); setEditing(null); }} />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Drawer open={!!detail} width={760} title={detail ? `${detail.modelName} / ${detail.versionCode}` : '计价详情'} onClose={() => setDetail(null)}>
|
|
||||||
{detail && <>
|
|
||||||
<Space wrap style={{ marginBottom: 12 }}><Tag>{detail.provider}</Tag><Tag>{detail.modelCategory}</Tag><Tag color="blue">{modeMap[detail.billingMode] || detail.billingMode}</Tag><Tag color={(statusMap[detail.publishStatus] || {}).color}>{(statusMap[detail.publishStatus] || {}).text}</Tag></Space>
|
|
||||||
<Typography.Paragraph>生效:{formatDate(detail.effectiveFrom)} ~ {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}<br />计算器:{detail.calculatorVersion}<br />规则 Hash:{detail.ruleContentHash || '-'}</Typography.Paragraph>
|
|
||||||
<Typography.Paragraph>来源:{detail.sourceUrl || '-'}<br />官方更新时间:{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}</Typography.Paragraph>
|
|
||||||
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto' }}>{JSON.stringify(detail.ruleJson, null, 2)}</pre>
|
|
||||||
<PricingRulePreview billingMode={detail.billingMode} calculatorVersion={detail.calculatorVersion} ruleJson={detail.ruleJson} />
|
|
||||||
</>}
|
|
||||||
</Drawer>
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminModelPricingRules;
|
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import ReactQuill from 'react-quill-new';
|
||||||
|
import 'react-quill-new/dist/quill.snow.css';
|
||||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
@@ -131,6 +133,7 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '内容', dataIndex: 'content', ellipsis: true,
|
title: '内容', dataIndex: 'content', ellipsis: true,
|
||||||
|
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型', dataIndex: 'type', width: 80,
|
title: '类型', dataIndex: 'type', width: 80,
|
||||||
@@ -210,8 +213,8 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
<Input placeholder="请输入消息标题" size="large" />
|
<Input placeholder="请输入消息标题" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="content" label="消息内容"
|
<Form.Item name="content" label="消息内容"
|
||||||
rules={[{ required: true, message: '请输入内容' }]}>
|
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
||||||
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
|
<ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
|
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import {
|
import {
|
||||||
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
|
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
|
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
@@ -22,6 +22,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
phone?: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
}>({
|
}>({
|
||||||
@@ -36,6 +37,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
getPaymentStats(filters),
|
getPaymentStats(filters),
|
||||||
getAdminPaymentOrders({
|
getAdminPaymentOrders({
|
||||||
...filters,
|
...filters,
|
||||||
|
phone: filters.phone,
|
||||||
page: orderPage,
|
page: orderPage,
|
||||||
pageSize: orderPageSize,
|
pageSize: orderPageSize,
|
||||||
}),
|
}),
|
||||||
@@ -61,6 +63,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
startDate: dayjs().format('YYYY-MM-DD'),
|
startDate: dayjs().format('YYYY-MM-DD'),
|
||||||
endDate: dayjs().format('YYYY-MM-DD'),
|
endDate: dayjs().format('YYYY-MM-DD'),
|
||||||
});
|
});
|
||||||
|
setOrderPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefund = async (orderNo: string) => {
|
const handleRefund = async (orderNo: string) => {
|
||||||
@@ -101,6 +104,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
|
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
|
||||||
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
|
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
|
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
|
||||||
render: (m: string) => {
|
render: (m: string) => {
|
||||||
@@ -237,28 +241,28 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
|
||||||
title={<Space><DollarOutlined />订单列表</Space>}>
|
title={<Space><DollarOutlined />订单列表</Space>}>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={8} md={4}>
|
||||||
<span style={{ marginRight: 8 }}>支付方式:</span>
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>支付方式</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
placeholder="全部"
|
placeholder="全部"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 150 }}
|
style={{ width: '100%' }}
|
||||||
value={filters.paymentMethod}
|
value={filters.paymentMethod}
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
|
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
|
||||||
>
|
>
|
||||||
<Option value="alipay">支付宝</Option>
|
<Option value="alipay">支付宝</Option>
|
||||||
<Option value="wechat">微信</Option>
|
<Option value="wechat">微信</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={8} md={4}>
|
||||||
<span style={{ marginRight: 8 }}>状态:</span>
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>状态</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
placeholder="全部"
|
placeholder="全部"
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 150 }}
|
style={{ width: '100%' }}
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
|
||||||
>
|
>
|
||||||
<Option value="paid">已支付</Option>
|
<Option value="paid">已支付</Option>
|
||||||
<Option value="pending">待支付</Option>
|
<Option value="pending">待支付</Option>
|
||||||
@@ -266,9 +270,21 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
<Option value="refunded">已退款</Option>
|
<Option value="refunded">已退款</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8}>
|
<Col xs={24} sm={8} md={5}>
|
||||||
<span style={{ marginRight: 8 }}>日期范围:</span>
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>手机号</Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索手机号"
|
||||||
|
allowClear
|
||||||
|
value={filters.phone}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
|
||||||
|
onPressEnter={() => { setOrderPage(1); load(); }}
|
||||||
|
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={7}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>日期范围</Typography.Text>
|
||||||
<RangePicker
|
<RangePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
value={[
|
value={[
|
||||||
dayjs(filters.startDate),
|
dayjs(filters.startDate),
|
||||||
dayjs(filters.endDate),
|
dayjs(filters.endDate),
|
||||||
@@ -276,9 +292,9 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={4}>
|
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
|
||||||
重置
|
重置筛选
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
|
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
|
||||||
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
|
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
|
|||||||
moduleType?: ReplicationModuleType;
|
moduleType?: ReplicationModuleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_ORDER = [
|
const V1_STEP_ORDER = [
|
||||||
'material_input',
|
'material_input',
|
||||||
'image_prompt_optimize',
|
'image_prompt_optimize',
|
||||||
'image_generate',
|
'image_generate',
|
||||||
@@ -44,6 +44,12 @@ const STEP_ORDER = [
|
|||||||
'video_generate',
|
'video_generate',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const V2_STEP_ORDER = [
|
||||||
|
'material_input',
|
||||||
|
'video_prompt_optimize',
|
||||||
|
'video_generate',
|
||||||
|
];
|
||||||
|
|
||||||
const STEP_DESCRIPTIONS: Record<string, string> = {
|
const STEP_DESCRIPTIONS: Record<string, string> = {
|
||||||
material_input: '参考素材、项目名称和核心内容点',
|
material_input: '参考素材、项目名称和核心内容点',
|
||||||
image_prompt_optimize: '图片 AI 提词优化结果',
|
image_prompt_optimize: '图片 AI 提词优化结果',
|
||||||
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
|
|||||||
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
|
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||||
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
|
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = moduleType === 'hot_opening_replicate'
|
const res = moduleType === 'hot_opening_replicate'
|
||||||
? await getAdminHotOpeningTaskDetail(projectId)
|
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
|
||||||
: await getAdminShotProjectDetail(projectId);
|
: await getAdminShotProjectDetail(projectId, flowVersion);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载复刻项目详情失败');
|
message.error(e?.message || '加载复刻项目详情失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [moduleType, projectId]);
|
}, [flowVersion, moduleType, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
|
|
||||||
const moduleValue = detail?.module || moduleType;
|
const moduleValue = detail?.module || moduleType;
|
||||||
const moduleName = getModuleLabel(moduleValue);
|
const moduleName = getModuleLabel(moduleValue);
|
||||||
|
const isV2 = detail?.flowVersion === 'v2';
|
||||||
|
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
|
||||||
|
|
||||||
const stepItems = useMemo(() => STEP_ORDER.map(code => {
|
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
|
||||||
const step = stepsByCode[code];
|
const step = stepsByCode[code];
|
||||||
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
|
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
|
||||||
if (step?.status === 'completed') status = 'finish';
|
if (step?.status === 'completed') status = 'finish';
|
||||||
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
description: step ? <StatusTag status={step.status} /> : '未创建',
|
description: step ? <StatusTag status={step.status} /> : '未创建',
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
}), [stepsByCode]);
|
}), [effectiveStepOrder, stepsByCode]);
|
||||||
|
|
||||||
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
|
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
|
||||||
|
|
||||||
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'video_prompt_optimize',
|
key: 'video_prompt_optimize',
|
||||||
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<Descriptions column={3} bordered size="small">
|
<Descriptions column={3} bordered size="small">
|
||||||
@@ -265,7 +275,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'video_generate',
|
key: 'video_generate',
|
||||||
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<Descriptions column={3} bordered size="small">
|
<Descriptions column={3} bordered size="small">
|
||||||
@@ -300,6 +310,9 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const visibleCollapseItems = isV2
|
||||||
|
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
|
||||||
|
: collapseItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
@@ -322,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
|
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
||||||
@@ -336,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
|
|
||||||
<Card title="最终结果预览">
|
<Card title="最终结果预览">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
||||||
|
{!isV2 ? (
|
||||||
<Card size="small" title="最终图片">
|
<Card size="small" title="最终图片">
|
||||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
<Card size="small" title="最终视频封面">
|
<Card size="small" title="最终视频封面">
|
||||||
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
|
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -348,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
|
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
|
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
|
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
|
createSystemConfig,
|
||||||
getGlobalResourceCapacity,
|
getGlobalResourceCapacity,
|
||||||
getSystemConfigs,
|
getSystemConfigs,
|
||||||
saveGlobalResourceCapacity,
|
saveGlobalResourceCapacity,
|
||||||
updateSystemConfig,
|
updateSystemConfig,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
uploadPdf,
|
uploadPdf,
|
||||||
|
uploadLoginVideo,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
||||||
|
|
||||||
@@ -39,9 +41,21 @@ const AdminSettings: React.FC = () => {
|
|||||||
getSystemConfigs(),
|
getSystemConfigs(),
|
||||||
getGlobalResourceCapacity(),
|
getGlobalResourceCapacity(),
|
||||||
]);
|
]);
|
||||||
|
// 确保 llm_media_as_base64 配置存在
|
||||||
|
if (!data.some(c => c.key === 'llm_media_as_base64')) {
|
||||||
|
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
|
||||||
|
}
|
||||||
setConfigs(data);
|
setConfigs(data);
|
||||||
const formValues: Record<string, any> = {};
|
const formValues: Record<string, any> = {};
|
||||||
data.forEach(c => { formValues[c.key] = c.value; });
|
data.forEach(c => { formValues[c.key] = c.value; });
|
||||||
|
// LLM 预扣积分默认值
|
||||||
|
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
|
||||||
|
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
|
||||||
|
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
|
||||||
|
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
|
||||||
|
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
|
||||||
|
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
|
||||||
|
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
|
||||||
formValues.resource_capacity_enabled = capacity.enabled;
|
formValues.resource_capacity_enabled = capacity.enabled;
|
||||||
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
|
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
|
||||||
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
|
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
|
||||||
@@ -56,13 +70,80 @@ const AdminSettings: React.FC = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
|
||||||
|
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
if (llmBillingEnabled) {
|
||||||
|
const holdKeys = [
|
||||||
|
'optimize_hold_credits',
|
||||||
|
'llm_hold_credits_default',
|
||||||
|
'llm_hold_credits_generation_record_prompt',
|
||||||
|
'llm_hold_credits_module_image_prompt',
|
||||||
|
'llm_hold_credits_module_video_prompt',
|
||||||
|
'llm_hold_credits_shot_video_analysis',
|
||||||
|
];
|
||||||
|
const invalidKey = holdKeys.find((key) => {
|
||||||
|
const numericValue = Number(values[key]);
|
||||||
|
return !Number.isFinite(numericValue) || numericValue <= 0;
|
||||||
|
});
|
||||||
|
if (invalidKey) {
|
||||||
|
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const llmManagedKeys = new Set([
|
||||||
|
'optimize_hold_credits',
|
||||||
|
'llm_billing_enabled',
|
||||||
|
'llm_hold_credits_default',
|
||||||
|
'llm_hold_credits_generation_record_prompt',
|
||||||
|
'llm_hold_credits_module_image_prompt',
|
||||||
|
'llm_hold_credits_module_video_prompt',
|
||||||
|
'llm_hold_credits_shot_video_analysis',
|
||||||
|
]);
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
|
if (llmManagedKeys.has(config.key)) continue;
|
||||||
const newVal = values[config.key];
|
const newVal = values[config.key];
|
||||||
if (newVal !== undefined && String(newVal) !== config.value) {
|
if (newVal !== undefined && String(newVal) !== config.value) {
|
||||||
await updateSystemConfig(config.id, String(newVal ?? ''));
|
await updateSystemConfig(config.id, String(newVal ?? ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
|
||||||
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
const normalizedValue = String(value);
|
||||||
|
const existing = configs.find(c => c.key === key);
|
||||||
|
if (existing) {
|
||||||
|
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
|
||||||
|
} else {
|
||||||
|
await createSystemConfig(key, normalizedValue, description);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabledConfig = [
|
||||||
|
'llm_billing_enabled',
|
||||||
|
values.llm_billing_enabled,
|
||||||
|
'是否启用 LLM 统一预扣与真实扣费结算',
|
||||||
|
] as const;
|
||||||
|
const llmHoldConfigs = [
|
||||||
|
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
|
||||||
|
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
|
||||||
|
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
|
||||||
|
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
|
||||||
|
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
|
||||||
|
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
|
||||||
|
if (!llmBillingEnabled) {
|
||||||
|
await saveManagedConfig(...enabledConfig);
|
||||||
|
}
|
||||||
|
for (const [key, value, description] of llmHoldConfigs) {
|
||||||
|
await saveManagedConfig(key, value, description);
|
||||||
|
}
|
||||||
|
if (llmBillingEnabled) {
|
||||||
|
await saveManagedConfig(...enabledConfig);
|
||||||
|
}
|
||||||
|
|
||||||
await saveGlobalResourceCapacity({
|
await saveGlobalResourceCapacity({
|
||||||
enabled: !!values.resource_capacity_enabled,
|
enabled: !!values.resource_capacity_enabled,
|
||||||
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
|
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
|
||||||
@@ -118,12 +199,62 @@ const AdminSettings: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoginVideoUpload = async (file: File) => {
|
||||||
|
setUploading('login_bg_video');
|
||||||
|
try {
|
||||||
|
const res = await uploadLoginVideo(file);
|
||||||
|
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
|
||||||
|
form.setFieldsValue({ login_bg_video: res.url });
|
||||||
|
const config = configs.find(c => c.key === 'login_bg_video');
|
||||||
|
if (config) {
|
||||||
|
await updateSystemConfig(config.id, res.url);
|
||||||
|
}
|
||||||
|
message.success('登录背景视频上传成功并已保存');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading('');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLoginVideo = async () => {
|
||||||
|
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
|
||||||
|
form.setFieldsValue({ login_bg_video: '' });
|
||||||
|
const config = configs.find(c => c.key === 'login_bg_video');
|
||||||
|
if (config) {
|
||||||
|
await updateSystemConfig(config.id, '');
|
||||||
|
}
|
||||||
|
message.success('已移除登录背景视频');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBase64 = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||||
|
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||||
|
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||||
|
} else {
|
||||||
|
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
|
||||||
|
config = res;
|
||||||
|
}
|
||||||
|
setConfigs(prev => {
|
||||||
|
const exists = prev.some(c => c.key === 'llm_media_as_base64');
|
||||||
|
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||||
|
return [...prev, config!];
|
||||||
|
});
|
||||||
|
message.success(`已${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||||
'其他配置': configs.filter(c => c.key === 'operation_manual'),
|
'其他配置': configs.filter(c => c.key === 'operation_manual'),
|
||||||
|
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFieldDescription = (config: SystemConfig): string => {
|
const getFieldDescription = (config: SystemConfig): string => {
|
||||||
@@ -139,6 +270,13 @@ const AdminSettings: React.FC = () => {
|
|||||||
user_login_credits: '用户每日登录赠送的积分数量',
|
user_login_credits: '用户每日登录赠送的积分数量',
|
||||||
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
|
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
|
||||||
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
|
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
|
||||||
|
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
|
||||||
|
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
|
||||||
|
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
|
||||||
|
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
|
||||||
|
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
|
||||||
|
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
|
||||||
|
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
|
||||||
};
|
};
|
||||||
return descMap[config.key] || config.description || '';
|
return descMap[config.key] || config.description || '';
|
||||||
};
|
};
|
||||||
@@ -268,8 +406,8 @@ const AdminSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
|
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
|
||||||
return <Input type="number" min={0} placeholder={config.description} size="large" />;
|
return <Input type="number" min={1} placeholder={config.description} size="large" />;
|
||||||
}
|
}
|
||||||
return <Input placeholder={config.description} size="large" />;
|
return <Input placeholder={config.description} size="large" />;
|
||||||
};
|
};
|
||||||
@@ -278,6 +416,190 @@ const AdminSettings: React.FC = () => {
|
|||||||
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
label: '网站基础设置',
|
||||||
|
children: (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
|
||||||
|
<div key={group} style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
{group}
|
||||||
|
</Typography.Text>
|
||||||
|
{group === '协议配置' ? (
|
||||||
|
groupedConfigs[group]?.map(config => (
|
||||||
|
<PdfUploadField key={config.id} config={config} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
groupedConfigs[group]?.map(config => (
|
||||||
|
<Form.Item
|
||||||
|
key={config.id}
|
||||||
|
name={config.key}
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||||
|
extra={getFieldDescription(config)}
|
||||||
|
>
|
||||||
|
{getFieldComponent(config)}
|
||||||
|
</Form.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'credits',
|
||||||
|
label: '用户积分配置',
|
||||||
|
children: (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
用户积分配置
|
||||||
|
</Typography.Text>
|
||||||
|
{groupedConfigs['用户积分配置']?.map(config => (
|
||||||
|
<Form.Item
|
||||||
|
key={config.id}
|
||||||
|
name={config.key}
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||||
|
extra={getFieldDescription(config)}
|
||||||
|
>
|
||||||
|
{getFieldComponent(config)}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
{/* AI创作预扣积分 - 固定显示 */}
|
||||||
|
<Form.Item
|
||||||
|
name="optimize_hold_credits"
|
||||||
|
label={<span style={{ fontWeight: 500 }}>兼容旧预扣积分数量</span>}
|
||||||
|
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
|
||||||
|
>
|
||||||
|
<Input type="number" min={0} placeholder="默认5" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
{[
|
||||||
|
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
|
||||||
|
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
|
||||||
|
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
|
||||||
|
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
|
||||||
|
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
|
||||||
|
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
|
||||||
|
].map(([name, label, extra]) => (
|
||||||
|
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
|
||||||
|
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'other',
|
||||||
|
label: '其他配置',
|
||||||
|
children: (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
其他配置
|
||||||
|
</Typography.Text>
|
||||||
|
{groupedConfigs['其他配置']?.map(config => (
|
||||||
|
<Form.Item
|
||||||
|
key={config.id}
|
||||||
|
name={config.key}
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||||
|
extra={getFieldDescription(config)}
|
||||||
|
>
|
||||||
|
{getFieldComponent(config)}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录背景视频 */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
登录背景视频
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||||
|
<Typography.Text strong>背景视频</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{form.getFieldValue('login_bg_video') && (
|
||||||
|
<Button size="small" danger onClick={handleRemoveLoginVideo}>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Upload
|
||||||
|
accept="video/*,image/gif,image/webp"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleLoginVideoUpload}
|
||||||
|
>
|
||||||
|
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
|
||||||
|
上传视频
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const url = form.getFieldValue('login_bg_video');
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
未设置,前台将默认使用 backimage.png
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
|
||||||
|
const isGif = url.toLowerCase().endsWith('.gif');
|
||||||
|
return isGif ? (
|
||||||
|
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={fullUrl}
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
|
||||||
|
支持 MP4、WebM、MOV、GIF、WebP,最大 50MB
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文字模型媒体编码 */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
文字模型媒体编码
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>图片/视频 base64 编码</Typography.Text>
|
||||||
|
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||||
|
开启后文字模型请求时将媒体转 base64 发送,而非 URL 链接
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Switch
|
||||||
|
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
|
||||||
|
onChange={handleToggleBase64}
|
||||||
|
checkedChildren="base64"
|
||||||
|
unCheckedChildren="链接"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ maxWidth: 720 }}>
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||||
@@ -292,78 +614,11 @@ const AdminSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||||
<Typography.Text type="secondary">管理站点基础信息、协议文件和SEO配置</Typography.Text>
|
<Typography.Text type="secondary">管理站点基础信息、用户积分和系统配置</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form form={form} layout="vertical">
|
<Tabs items={tabItems} defaultActiveKey="basic" />
|
||||||
{Object.entries(groupedConfigs).map(([group, items]) => (
|
|
||||||
<div key={group} style={{ marginBottom: 24 }}>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
|
||||||
{group}
|
|
||||||
</Typography.Text>
|
|
||||||
{group === '协议配置' ? (
|
|
||||||
items.map(config => (
|
|
||||||
<PdfUploadField key={config.id} config={config} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
items.map(config => (
|
|
||||||
<Form.Item
|
|
||||||
key={config.id}
|
|
||||||
name={config.key}
|
|
||||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
|
||||||
extra={getFieldDescription(config)}
|
|
||||||
>
|
|
||||||
{getFieldComponent(config)}
|
|
||||||
</Form.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 4 }}>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
|
||||||
资源空间管控
|
|
||||||
</Typography.Text>
|
|
||||||
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
|
|
||||||
<Space align="start" style={{ marginBottom: 16 }}>
|
|
||||||
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
|
|
||||||
<div>
|
|
||||||
<Typography.Text strong>全局生成资源容量上限</Typography.Text>
|
|
||||||
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
|
|
||||||
开启后会按用户当前有效资源占用量进行提交前拦截;用户个人配置存在时优先级高于全局配置。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
<Form.Item
|
|
||||||
name="resource_capacity_enabled"
|
|
||||||
label="启用全局容量管控"
|
|
||||||
valuePropName="checked"
|
|
||||||
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
|
||||||
</Form.Item>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
|
|
||||||
<Form.Item
|
|
||||||
name="resource_capacity_limit_value"
|
|
||||||
label="容量数值"
|
|
||||||
extra="最小为1,不能为负数,最多支持3位小数。"
|
|
||||||
rules={[{ required: true, message: '请输入容量数值' }]}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="resource_capacity_limit_unit"
|
|
||||||
label="容量单位"
|
|
||||||
extra="MB / GB / TB 固定枚举"
|
|
||||||
rules={[{ required: true, message: '请选择容量单位' }]}
|
|
||||||
>
|
|
||||||
<Select size="large" options={capacityUnitOptions} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getAdminShotTaskSets } from '../api';
|
import { getAdminShotTaskSets } from '../api';
|
||||||
import type { ShotTaskSetOut } from '../types';
|
import type { ShotTaskSetOut } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -35,28 +36,14 @@ const SPLIT_STATUS_OPTIONS = [
|
|||||||
{ value: 'retry_waiting', label: '等待重试' },
|
{ value: 'retry_waiting', label: '等待重试' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
|
||||||
pending_analysis: { color: 'default', text: '等待分析' },
|
|
||||||
analyzing: { color: 'processing', text: '分析中' },
|
|
||||||
analysis_completed: { color: 'success', text: '分析完成' },
|
|
||||||
analysis_failed: { color: 'error', text: '分析失败' },
|
|
||||||
splitting: { color: 'warning', text: '拆镜中' },
|
|
||||||
split_completed: { color: 'success', text: '拆镜完成' },
|
|
||||||
partial_failed: { color: 'orange', text: '部分失败' },
|
|
||||||
failed: { color: 'error', text: '失败' },
|
|
||||||
none: { color: 'default', text: '未拆镜' },
|
|
||||||
pending: { color: 'default', text: '待处理' },
|
|
||||||
processing: { color: 'warning', text: '处理中' },
|
|
||||||
completed: { color: 'success', text: '完成' },
|
|
||||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||||
|
|
||||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' }> = ({ status, kind = 'task' }) => {
|
||||||
if (!status) return <Tag>-</Tag>;
|
if (!status) return <Tag>-</Tag>;
|
||||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
const meta = kind === 'analysis'
|
||||||
|
? getShotAnalysisStatusMeta(status)
|
||||||
|
: (kind === 'split' ? getShotSplitStatusMeta(status) : getShotTaskStatusMeta(status));
|
||||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,6 +96,25 @@ const AdminShotReplications: React.FC = () => {
|
|||||||
load();
|
load();
|
||||||
}, [load, reloadKey]);
|
}, [load, reloadKey]);
|
||||||
|
|
||||||
|
const hasActiveTasks = items.some((item) => (
|
||||||
|
getShotTaskStatusMeta(item.status).active
|
||||||
|
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|
||||||
|
|| getShotSplitStatusMeta(item.splitStatus).active
|
||||||
|
));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveTasks) return undefined;
|
||||||
|
const refresh = () => {
|
||||||
|
if (document.visibilityState === 'visible') void load();
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(refresh, 30000);
|
||||||
|
document.addEventListener('visibilitychange', refresh);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener('visibilitychange', refresh);
|
||||||
|
};
|
||||||
|
}, [hasActiveTasks, load]);
|
||||||
|
|
||||||
const doSearch = () => {
|
const doSearch = () => {
|
||||||
setQueryKeyword(inputKeyword.trim());
|
setQueryKeyword(inputKeyword.trim());
|
||||||
setQueryUserId(inputUserId.trim());
|
setQueryUserId(inputUserId.trim());
|
||||||
@@ -134,16 +140,15 @@ const AdminShotReplications: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<Card>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space>
|
||||||
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
|
<CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
<div>
|
<Typography.Text strong style={{ fontSize: 16 }}>拆镜复刻</Typography.Text>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>拆镜复刻</Typography.Title>
|
<Tag color="purple">{total} 条记录</Tag>
|
||||||
<Typography.Text type="secondary">只读排查页面:查看拆镜总任务、AI 分析结果、切片列表和关联复刻项目。</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
|
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
|
||||||
@@ -186,9 +191,9 @@ const AdminShotReplications: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} kind="task" /> },
|
||||||
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
|
||||||
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
|
||||||
{
|
{
|
||||||
title: '切片进度',
|
title: '切片进度',
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -211,9 +216,7 @@ const AdminShotReplications: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Empty,
|
Empty,
|
||||||
Input,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
@@ -23,6 +22,7 @@ import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDet
|
|||||||
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
|
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
|
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
|
||||||
|
import { getShotAnalysisStatusMeta, getShotReplicateStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
|
||||||
|
|
||||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||||
@@ -55,25 +55,6 @@ const REPLICATE_STATUS_OPTIONS = [
|
|||||||
{ value: 'failed', label: '复刻失败' },
|
{ value: 'failed', label: '复刻失败' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
|
||||||
pending_analysis: { color: 'default', text: '等待分析' },
|
|
||||||
analyzing: { color: 'processing', text: '分析中' },
|
|
||||||
analysis_completed: { color: 'success', text: '分析完成' },
|
|
||||||
analysis_failed: { color: 'error', text: '分析失败' },
|
|
||||||
splitting: { color: 'warning', text: '拆镜中' },
|
|
||||||
split_completed: { color: 'success', text: '拆镜完成' },
|
|
||||||
partial_failed: { color: 'orange', text: '部分失败' },
|
|
||||||
failed: { color: 'error', text: '失败' },
|
|
||||||
none: { color: 'default', text: '未拆镜' },
|
|
||||||
pending: { color: 'default', text: '待处理' },
|
|
||||||
processing: { color: 'warning', text: '处理中' },
|
|
||||||
completed: { color: 'success', text: '完成' },
|
|
||||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
|
||||||
not_required: { color: 'default', text: '无需分析' },
|
|
||||||
not_started: { color: 'default', text: '未复刻' },
|
|
||||||
project_created: { color: 'processing', text: '已创建项目' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiUrl = (url?: string | null): string => {
|
const apiUrl = (url?: string | null): string => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
const value = String(url).trim();
|
const value = String(url).trim();
|
||||||
@@ -85,9 +66,13 @@ const apiUrl = (url?: string | null): string => {
|
|||||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||||
|
|
||||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' | 'replicate' }> = ({ status, kind = 'task' }) => {
|
||||||
if (!status) return <Tag>-</Tag>;
|
if (!status) return <Tag>-</Tag>;
|
||||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
const meta = kind === 'analysis'
|
||||||
|
? getShotAnalysisStatusMeta(status)
|
||||||
|
: (kind === 'split'
|
||||||
|
? getShotSplitStatusMeta(status)
|
||||||
|
: (kind === 'replicate' ? getShotReplicateStatusMeta(status) : getShotTaskStatusMeta(status)));
|
||||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,6 +162,32 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
|||||||
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
|
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
|
||||||
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
|
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
|
||||||
|
|
||||||
|
const hasActiveTasks = Boolean(detail && (
|
||||||
|
getShotTaskStatusMeta(detail.status).active
|
||||||
|
|| getShotAnalysisStatusMeta(detail.analysisStatus).active
|
||||||
|
|| getShotSplitStatusMeta(detail.splitStatus).active
|
||||||
|
|| segments.some((item) => (
|
||||||
|
getShotSplitStatusMeta(item.splitStatus).active
|
||||||
|
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|
||||||
|
|| getShotReplicateStatusMeta(item.replicateStatus).active
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveTasks) return undefined;
|
||||||
|
const refresh = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
void loadDetail();
|
||||||
|
void loadSegments();
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(refresh, 20000);
|
||||||
|
document.addEventListener('visibilitychange', refresh);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener('visibilitychange', refresh);
|
||||||
|
};
|
||||||
|
}, [hasActiveTasks, loadDetail, loadSegments]);
|
||||||
|
|
||||||
const openSegmentDetail = async (segmentId: string) => {
|
const openSegmentDetail = async (segmentId: string) => {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
setSegmentDetail(null);
|
setSegmentDetail(null);
|
||||||
@@ -224,9 +235,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
|||||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||||
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
<Descriptions.Item label="总状态"><StatusTag status={detail.status} kind="task" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
|
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
|
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} kind="split" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount},失败 {detail.failedSegmentCount}</Descriptions.Item>
|
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount},失败 {detail.failedSegmentCount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
|
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||||
@@ -274,9 +285,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
|||||||
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan">自定义</Tag> },
|
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan">自定义</Tag> },
|
||||||
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
|
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
|
||||||
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
||||||
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
|
||||||
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
|
||||||
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="replicate" /> },
|
||||||
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
|
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
|
||||||
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
|
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
@@ -284,9 +295,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
|||||||
width: 220,
|
width: 220,
|
||||||
render: (_, record) => record.moduleProjectId ? (
|
render: (_, record) => record.moduleProjectId ? (
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
|
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
||||||
<StatusTag status={record.moduleProjectStatus} />
|
<Space size={4}>
|
||||||
|
<StatusTag status={record.moduleProjectStatus} kind="replicate" />
|
||||||
|
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
|
||||||
|
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
) : <Tag>未创建</Tag>,
|
) : <Tag>未创建</Tag>,
|
||||||
},
|
},
|
||||||
@@ -310,10 +326,11 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
|||||||
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
|
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
|
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||||
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
|
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} kind="split" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
|
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
|
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} kind="replicate" /></Descriptions.Item>
|
||||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
|
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
|
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
|
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
adjustCredits,
|
adjustCredits,
|
||||||
@@ -191,7 +191,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
user_type: userType,
|
user_type: userType,
|
||||||
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
||||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
|
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
|
||||||
});
|
});
|
||||||
message.success('用户创建成功');
|
message.success('用户创建成功');
|
||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
@@ -411,6 +411,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>超级管理员</Tag>}
|
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>超级管理员</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
||||||
|
<div style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -693,6 +694,24 @@ const AdminUsers: React.FC = () => {
|
|||||||
{creditModal.user?.credits.toLocaleString()}
|
{creditModal.user?.credits.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>快捷操作</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
|
||||||
|
+1000 / 积分赠送
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
|
||||||
|
+500 / 积分赠送
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
|
||||||
|
-500 / 积分扣除
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
|
||||||
|
-1000 / 积分扣除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="amount" label="积分变动"
|
<Form.Item name="amount" label="积分变动"
|
||||||
rules={[{ required: true, message: '请输入积分数量' }]}>
|
rules={[{ required: true, message: '请输入积分数量' }]}>
|
||||||
@@ -882,7 +901,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="private_portrait_asset_limit"
|
name="private_portrait_asset_limit"
|
||||||
label="私域人像素材总量上限"
|
label="私域人像素材总量上限"
|
||||||
initialValue={5}
|
initialValue={50}
|
||||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||||
@@ -25,6 +25,8 @@ interface VideoEngine {
|
|||||||
supportsUniversalReference: boolean;
|
supportsUniversalReference: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
multiGenerationEnabled: boolean;
|
||||||
|
maxGenerationCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonArray(val: unknown): any[] {
|
function parseJsonArray(val: unknown): any[] {
|
||||||
@@ -40,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -80,6 +83,8 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
supports_universal_reference: values.supportsUniversalReference ?? true,
|
supports_universal_reference: values.supportsUniversalReference ?? true,
|
||||||
is_active: values.isActive ?? true,
|
is_active: values.isActive ?? true,
|
||||||
priority: values.priority ?? 0,
|
priority: values.priority ?? 0,
|
||||||
|
multi_generation_enabled: values.multiGenerationEnabled ?? false,
|
||||||
|
max_generation_count: values.maxGenerationCount ?? 1,
|
||||||
};
|
};
|
||||||
if (modal.engine) {
|
if (modal.engine) {
|
||||||
await saveVideoEngine({ id: modal.engine.id, ...payload });
|
await saveVideoEngine({ id: modal.engine.id, ...payload });
|
||||||
@@ -115,6 +120,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
|
multiGenerationEnabled: false, maxGenerationCount: 1,
|
||||||
maxDuration: 30,
|
maxDuration: 30,
|
||||||
maxImageCount: 2,
|
maxImageCount: 2,
|
||||||
maxVideoCount: 0,
|
maxVideoCount: 0,
|
||||||
@@ -180,6 +186,18 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
||||||
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
|
||||||
|
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
|
||||||
|
render: (v: number, r: VideoEngine) => (
|
||||||
|
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
|
||||||
|
最多 {r.multiGenerationEnabled ? (v || 1) : 1} 份
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'isActive', width: 80,
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||||
@@ -315,7 +333,19 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong>多份生成能力</Typography.Text>
|
||||||
|
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
|
||||||
|
管理后台只控制是否允许客户端选择多份及最大数量;客户端每次可在 1 到上限之间选择。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
{ value: 0, label: '0 (默认)' },
|
{ value: 0, label: '0 (默认)' },
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
|
||||||
|
import type {
|
||||||
|
VideoUpscaleConfigData,
|
||||||
|
VideoUpscaleProcessorKey,
|
||||||
|
VideoUpscaleResolutionRule,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
|
||||||
|
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpeg(crop)' },
|
||||||
|
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
|
||||||
|
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
|
||||||
|
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultRule = (): VideoUpscaleResolutionRule => ({
|
||||||
|
targetResolution: '1080p',
|
||||||
|
providerGenerationResolution: '720p',
|
||||||
|
processorKey: 'local_ffmpeg_crop_v1',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
|
||||||
|
return {
|
||||||
|
enabled: !!data.enabled,
|
||||||
|
version: Number(data.version || 1),
|
||||||
|
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
|
||||||
|
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSavePayload(data: VideoUpscaleConfigData) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
enabled: data.enabled,
|
||||||
|
version: data.version,
|
||||||
|
delete_source_after_success: data.deleteSourceAfterSuccess,
|
||||||
|
rules: data.rules.map((rule) => ({
|
||||||
|
target_resolution: rule.targetResolution,
|
||||||
|
provider_generation_resolution: rule.providerGenerationResolution,
|
||||||
|
processor_key: rule.processorKey,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminVideoUpscale: React.FC = () => {
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getVideoUpscaleConfig();
|
||||||
|
setConfig(normalizeConfig(result.data));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
|
||||||
|
setConfig((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRule = (index: number) => {
|
||||||
|
setConfig((current) => current ? {
|
||||||
|
...current,
|
||||||
|
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
} : current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!config) return;
|
||||||
|
const targets = config.rules
|
||||||
|
.filter((item) => item.enabled)
|
||||||
|
.map((item) => item.targetResolution.trim().toLowerCase());
|
||||||
|
if (new Set(targets).size !== targets.length) {
|
||||||
|
message.error('同一个客户目标分辨率只能存在一条启用规则');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await saveVideoUpscaleConfig(toSavePayload(config));
|
||||||
|
setConfig(normalizeConfig(result.data));
|
||||||
|
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !config) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card>
|
||||||
|
<Row justify="space-between" align="middle" gutter={[16, 16]}>
|
||||||
|
<Col>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>视频超分配置</Title>
|
||||||
|
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
|
||||||
|
规则只按客户选择的目标分辨率匹配。视频比例和最终像素由客户任务参数在创建任务时自动计算并固化快照。
|
||||||
|
</Paragraph>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Tag>配置版本 {config.version}</Tag>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void load()}>重新加载</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
|
||||||
|
<Space>
|
||||||
|
<Text strong>全局开启超分</Text>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">关闭后新任务走原流程,已经创建的任务仍按自身快照执行。</Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Text strong>超分成功后删除源视频</Text>
|
||||||
|
<Switch
|
||||||
|
checked={config.deleteSourceAfterSuccess}
|
||||||
|
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">
|
||||||
|
默认开启。关闭仅用于调试,会保留超分前源视频并持续占用服务器磁盘;超分失败时始终保留源视频。
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="目标分辨率规则"
|
||||||
|
extra={<Button icon={<PlusOutlined />} onClick={addRule}>新增规则</Button>}
|
||||||
|
>
|
||||||
|
{config.rules.length === 0 ? (
|
||||||
|
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
{config.rules.map((rule, index) => {
|
||||||
|
const duplicate = rule.enabled && config.rules.filter(
|
||||||
|
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
|
||||||
|
).length > 1;
|
||||||
|
return (
|
||||||
|
<Card key={`${index}-${rule.targetResolution}`} size="small">
|
||||||
|
<Row gutter={[12, 12]} align="bottom">
|
||||||
|
<Col xs={24} md={5}>
|
||||||
|
<Text type="secondary">客户选择分辨率</Text>
|
||||||
|
<Select
|
||||||
|
value={rule.targetResolution}
|
||||||
|
options={RESOLUTION_OPTIONS}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
onChange={(value) => updateRule(index, { targetResolution: value })}
|
||||||
|
status={duplicate ? 'error' : undefined}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={5}>
|
||||||
|
<Text type="secondary">实际生成分辨率</Text>
|
||||||
|
<Select
|
||||||
|
value={rule.providerGenerationResolution}
|
||||||
|
options={RESOLUTION_OPTIONS}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={7}>
|
||||||
|
<Text type="secondary">处理方式</Text>
|
||||||
|
<Select
|
||||||
|
value={rule.processorKey}
|
||||||
|
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
onChange={(value) => updateRule(index, { processorKey: value })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={2}>
|
||||||
|
<Text type="secondary">启用</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={2}>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => modal.confirm({
|
||||||
|
title: '删除这条超分规则?',
|
||||||
|
onOk: () => removeRule(index),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">
|
||||||
|
客户选择该目标分辨率时,无论横屏、竖屏或方形比例,都按本规则选择的实际分辨率生成;最终像素由任务比例自动计算。
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminVideoUpscale;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
|
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
|
||||||
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
||||||
|
import { copyToClipboard } from '../../../utils/clipboard';
|
||||||
|
|
||||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||||
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
|
|||||||
|
|
||||||
const copyUrl = async () => {
|
const copyUrl = async () => {
|
||||||
if (!resolvedUrl) return;
|
if (!resolvedUrl) return;
|
||||||
try {
|
const ok = await copyToClipboard(resolvedUrl);
|
||||||
await navigator.clipboard.writeText(resolvedUrl);
|
message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
|
||||||
message.success('资源地址已复制');
|
|
||||||
} catch {
|
|
||||||
message.error('复制失败,请手动复制');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tools = resolvedUrl ? (
|
const tools = resolvedUrl ? (
|
||||||
|
|||||||
@@ -21,13 +21,22 @@ const STATUS_LABELS: Record<string, LabelMeta> = {
|
|||||||
|
|
||||||
// 生成任务 pipeline / download stage
|
// 生成任务 pipeline / download stage
|
||||||
creating_provider_task: { text: '创建远端任务', color: 'processing' },
|
creating_provider_task: { text: '创建远端任务', color: 'processing' },
|
||||||
|
provider_result_staged: { text: '供应商结果已暂存', color: 'processing' },
|
||||||
waiting_remote: { text: '等待远端结果', color: 'processing' },
|
waiting_remote: { text: '等待远端结果', color: 'processing' },
|
||||||
polling: { text: '轮询远端结果', color: 'processing' },
|
polling: { text: '轮询远端结果', color: 'processing' },
|
||||||
result_ready: { text: '结果已就绪', color: 'success' },
|
result_ready: { text: '结果已就绪', color: 'success' },
|
||||||
|
download_queued: { text: '下载已入队', color: 'processing' },
|
||||||
downloading: { text: '下载中', color: 'processing' },
|
downloading: { text: '下载中', color: 'processing' },
|
||||||
done: { text: '已完成', color: 'success' },
|
done: { text: '已完成', color: 'success' },
|
||||||
download_failed: { text: '下载失败', color: 'error' },
|
download_failed: { text: '下载失败', color: 'error' },
|
||||||
retry_waiting: { text: '等待重试', color: 'orange' },
|
retry_waiting: { text: '等待重试', color: 'orange' },
|
||||||
|
upscale_queued: { text: '超分已入队', color: 'purple' },
|
||||||
|
upscale_processing: { text: '本地超分处理中', color: 'purple' },
|
||||||
|
upscale_polling: { text: '轮询远程超分', color: 'purple' },
|
||||||
|
upscale_downloading: { text: '下载超分结果', color: 'purple' },
|
||||||
|
upscale_finalizing: { text: '超分结果最终化', color: 'purple' },
|
||||||
|
upscale_retry_waiting: { text: '超分等待重试', color: 'orange' },
|
||||||
|
upscale_failed: { text: '超分失败', color: 'error' },
|
||||||
|
|
||||||
// 拆镜总任务状态
|
// 拆镜总任务状态
|
||||||
pending_analysis: { text: '等待分析', color: 'default' },
|
pending_analysis: { text: '等待分析', color: 'default' },
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
|
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
|
||||||
text: '民众普康 AI',
|
text: '民众智创',
|
||||||
opacityLevel: 2,
|
opacityLevel: 2,
|
||||||
fontSizePx: 28,
|
fontSizePx: 28,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
|
|||||||
marginX: 24,
|
marginX: 24,
|
||||||
marginY: 24,
|
marginY: 24,
|
||||||
textWatermark: {
|
textWatermark: {
|
||||||
text: '民众普康 AI',
|
text: '民众智创',
|
||||||
opacityLevel: 2,
|
opacityLevel: 2,
|
||||||
fontSizePx: 28,
|
fontSizePx: 28,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const positionOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const defaultTextWatermark = {
|
const defaultTextWatermark = {
|
||||||
text: '民众普康 AI',
|
text: '民众智创',
|
||||||
opacityLevel: 2,
|
opacityLevel: 2,
|
||||||
fontSizePx: 28,
|
fontSizePx: 28,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
|||||||
+134
-120
@@ -199,6 +199,30 @@ export interface AdminUser {
|
|||||||
privatePortraitAssetLimit: number;
|
privatePortraitAssetLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyCredit {
|
||||||
|
date: string;
|
||||||
|
module: string;
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamCredit {
|
||||||
|
teamName: string;
|
||||||
|
teamId: string | null;
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelUsageOut {
|
||||||
|
modelName: string;
|
||||||
|
provider: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoParamOut {
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalProjects: number;
|
totalProjects: number;
|
||||||
@@ -214,6 +238,13 @@ export interface AdminStats {
|
|||||||
lastPeriodRecords: number;
|
lastPeriodRecords: number;
|
||||||
lastPeriodRevenue: number;
|
lastPeriodRevenue: number;
|
||||||
lastPeriodCreditsConsumed: number;
|
lastPeriodCreditsConsumed: number;
|
||||||
|
dailyCreditsByModule: DailyCredit[];
|
||||||
|
periodCreditsByModule: DailyCredit[];
|
||||||
|
creditsByTeam: TeamCredit[];
|
||||||
|
modelUsage: ModelUsageOut[];
|
||||||
|
videoResolutionUsage: VideoParamOut[];
|
||||||
|
videoRatioUsage: VideoParamOut[];
|
||||||
|
videoDurationUsage: VideoParamOut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentStats {
|
export interface PaymentStats {
|
||||||
@@ -281,6 +312,12 @@ export interface GenerationAiImageEngine {
|
|||||||
supportedSizes: Record<string, Record<string, string>>;
|
supportedSizes: Record<string, Record<string, string>>;
|
||||||
defaultSize: string;
|
defaultSize: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
multiGenerationEnabled: boolean;
|
||||||
|
maxGenerationCount: number;
|
||||||
|
multiImageMaxImages: number;
|
||||||
|
maxReferenceImageCount: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationAiVideoEngine {
|
export interface GenerationAiVideoEngine {
|
||||||
@@ -299,6 +336,10 @@ export interface GenerationAiVideoEngine {
|
|||||||
supportsFirstLastFrame?: boolean;
|
supportsFirstLastFrame?: boolean;
|
||||||
supportsUniversalReference?: boolean;
|
supportsUniversalReference?: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
multiGenerationEnabled: boolean;
|
||||||
|
maxGenerationCount: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationAiEnginesResponse {
|
export interface GenerationAiEnginesResponse {
|
||||||
@@ -326,9 +367,37 @@ export interface GenerationAiEngineOption {
|
|||||||
supportsFirstLastFrame?: boolean;
|
supportsFirstLastFrame?: boolean;
|
||||||
supportsUniversalReference?: boolean;
|
supportsUniversalReference?: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
multiGenerationEnabled?: boolean;
|
||||||
|
maxGenerationCount?: number;
|
||||||
|
multiImageMaxImages?: number;
|
||||||
|
maxReferenceImageCount?: number;
|
||||||
genType: GenerationAiGenType;
|
genType: GenerationAiGenType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GenerationPipelineStage =
|
||||||
|
| 'queued'
|
||||||
|
| 'preparing'
|
||||||
|
| 'creating_provider_task'
|
||||||
|
| 'provider_result_staged'
|
||||||
|
| 'waiting_remote'
|
||||||
|
| 'polling'
|
||||||
|
| 'result_ready'
|
||||||
|
| 'download_queued'
|
||||||
|
| 'downloading'
|
||||||
|
| 'retry_waiting'
|
||||||
|
| 'upscale_queued'
|
||||||
|
| 'upscale_processing'
|
||||||
|
| 'upscale_polling'
|
||||||
|
| 'upscale_downloading'
|
||||||
|
| 'upscale_finalizing'
|
||||||
|
| 'upscale_retry_waiting'
|
||||||
|
| 'upscale_failed'
|
||||||
|
| 'done'
|
||||||
|
| 'failed'
|
||||||
|
| 'timeout'
|
||||||
|
| 'download_failed'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
export interface AdminGenerationRecord {
|
export interface AdminGenerationRecord {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -342,8 +411,10 @@ export interface AdminGenerationRecord {
|
|||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
|
status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
|
||||||
|
pipelineStage?: GenerationPipelineStage | null;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
videoCoverUrl?: string;
|
videoCoverUrl?: string;
|
||||||
|
videoUpscaleEnabled?: boolean;
|
||||||
references?: GenerationAIMediaReference[] | null;
|
references?: GenerationAIMediaReference[] | null;
|
||||||
creditsCost: number;
|
creditsCost: number;
|
||||||
textCreditsCost: number;
|
textCreditsCost: number;
|
||||||
@@ -358,6 +429,10 @@ export interface AdminGenerationRecord {
|
|||||||
imageTokensUsed?: number;
|
imageTokensUsed?: number;
|
||||||
imageProportion?: string;
|
imageProportion?: string;
|
||||||
imagePx?: string;
|
imagePx?: string;
|
||||||
|
engineId?: string;
|
||||||
|
engineName?: string;
|
||||||
|
engineSnapshot?: Record<string, unknown> | null;
|
||||||
|
includeMediaReferences?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
|
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
|
||||||
@@ -399,7 +474,11 @@ export interface GenerationAITaskOut {
|
|||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
genType: GenerationAiGenType | string;
|
genType: GenerationAiGenType | string;
|
||||||
generationMode?: string | null;
|
generationMode?: string | null;
|
||||||
pipelineStage?: string | null;
|
parentTaskId?: string | null;
|
||||||
|
generationCount: number;
|
||||||
|
generationIndex?: number | null;
|
||||||
|
displayStatus?: string | null;
|
||||||
|
pipelineStage?: GenerationPipelineStage | null;
|
||||||
status: GenerationAITaskStatus;
|
status: GenerationAITaskStatus;
|
||||||
originalPrompt: string;
|
originalPrompt: string;
|
||||||
optimizedPrompt?: string | null;
|
optimizedPrompt?: string | null;
|
||||||
@@ -428,6 +507,7 @@ export interface GenerationAITaskOut {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
generatedAt?: string | null;
|
generatedAt?: string | null;
|
||||||
|
childItems: GenerationAITaskOut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationAITaskListOut {
|
export interface GenerationAITaskListOut {
|
||||||
@@ -531,6 +611,9 @@ export interface ReplicationProjectDetailOut {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
status: ModuleReplicationStatus;
|
status: ModuleReplicationStatus;
|
||||||
currentStepCode?: string | null;
|
currentStepCode?: string | null;
|
||||||
|
flowVersion?: 'v1' | 'v2' | string | null;
|
||||||
|
stepCount?: number;
|
||||||
|
stepIoSchemaVersion?: string | null;
|
||||||
finalImageUrl?: string | null;
|
finalImageUrl?: string | null;
|
||||||
finalVideoUrl?: string | null;
|
finalVideoUrl?: string | null;
|
||||||
finalVideoCoverUrl?: string | null;
|
finalVideoCoverUrl?: string | null;
|
||||||
@@ -553,6 +636,8 @@ export interface HotOpeningTaskListItemOut {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
status: ModuleReplicationStatus;
|
status: ModuleReplicationStatus;
|
||||||
currentStepCode?: string | null;
|
currentStepCode?: string | null;
|
||||||
|
flowVersion?: 'v1' | 'v2' | string | null;
|
||||||
|
stepCount?: number;
|
||||||
sourceProjectName?: string | null;
|
sourceProjectName?: string | null;
|
||||||
targetProjectName?: string | null;
|
targetProjectName?: string | null;
|
||||||
coreContentPoint?: string | null;
|
coreContentPoint?: string | null;
|
||||||
@@ -666,6 +751,7 @@ export interface ShotSegmentOut {
|
|||||||
moduleProjectTitle?: string | null;
|
moduleProjectTitle?: string | null;
|
||||||
moduleProjectStatus?: string | null;
|
moduleProjectStatus?: string | null;
|
||||||
moduleProjectCurrentStepCode?: string | null;
|
moduleProjectCurrentStepCode?: string | null;
|
||||||
|
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
}
|
}
|
||||||
@@ -796,20 +882,6 @@ export interface AdminCreditRecordSummary {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
attachmentImageCount: number;
|
|
||||||
attachmentVideoCount: number;
|
|
||||||
attachmentAudioCount: number;
|
|
||||||
attachmentTotalCount: number;
|
|
||||||
generatedImageCount: number;
|
|
||||||
generatedVideoCount: number;
|
|
||||||
generatedTotalCount: number;
|
|
||||||
providerCostCalculatedTotal: string;
|
|
||||||
providerCostEstimatedTotal: string;
|
|
||||||
providerCostCombinedTotal: string;
|
|
||||||
providerCostTotal: string;
|
|
||||||
providerCostPendingCount: number;
|
|
||||||
providerCostEstimatedCount: number;
|
|
||||||
providerCostAbnormalCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminCreditRecord {
|
export interface AdminCreditRecord {
|
||||||
@@ -841,6 +913,7 @@ export interface AdminCreditRecord {
|
|||||||
chargeKind?: string;
|
chargeKind?: string;
|
||||||
chargeKindLabel?: string;
|
chargeKindLabel?: string;
|
||||||
chargeAction?: string;
|
chargeAction?: string;
|
||||||
|
chargeActionLabel?: string;
|
||||||
creditSubject?: string;
|
creditSubject?: string;
|
||||||
creditSubjectLabel?: string;
|
creditSubjectLabel?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
@@ -862,38 +935,6 @@ export interface AdminCreditRecord {
|
|||||||
engineName?: string;
|
engineName?: string;
|
||||||
engineProvider?: string;
|
engineProvider?: string;
|
||||||
engineModelName?: string;
|
engineModelName?: string;
|
||||||
pricingRuleId?: string;
|
|
||||||
pricingVersionCode?: string;
|
|
||||||
pricingBillingMode?: string;
|
|
||||||
pricingBillingModeLabel?: string;
|
|
||||||
pricingCalculatorVersion?: ModelPricingCalculatorVersion;
|
|
||||||
pricingUsageSource?: string;
|
|
||||||
pricingReferenceAt?: string;
|
|
||||||
pricingEffectiveFrom?: string;
|
|
||||||
pricingEffectiveTo?: string;
|
|
||||||
pricingSnapshotHash?: string;
|
|
||||||
providerCostCurrency?: string;
|
|
||||||
providerCostAmount?: string;
|
|
||||||
providerCostStatus?: string;
|
|
||||||
providerCostStatusLabel?: string;
|
|
||||||
providerCostCalculatedAt?: string;
|
|
||||||
providerCostFinalizedAt?: string;
|
|
||||||
providerCostIsEstimated?: boolean;
|
|
||||||
providerUsagePrimary?: boolean;
|
|
||||||
attachmentImageCount: number;
|
|
||||||
attachmentVideoCount: number;
|
|
||||||
attachmentAudioCount: number;
|
|
||||||
attachmentTotalCount: number;
|
|
||||||
attachmentVideoDurationSeconds?: string;
|
|
||||||
attachmentAudioDurationSeconds?: string;
|
|
||||||
requestedOutputCount: number;
|
|
||||||
generatedImageCount: number;
|
|
||||||
generatedVideoCount: number;
|
|
||||||
generatedTotalCount: number;
|
|
||||||
pricingSnapshotJson?: Record<string, any> | null;
|
|
||||||
usageSnapshotJson?: Record<string, any> | null;
|
|
||||||
attachmentSnapshotJson?: Record<string, any> | null;
|
|
||||||
generationSnapshotJson?: Record<string, any> | null;
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,86 +957,14 @@ export interface AdminCreditRecordQueryParams {
|
|||||||
creditSubject?: string;
|
creditSubject?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
chargeKind?: string;
|
chargeKind?: string;
|
||||||
|
chargeAction?: string;
|
||||||
sourceModule?: string;
|
sourceModule?: string;
|
||||||
sourceStepCode?: string;
|
sourceStepCode?: string;
|
||||||
billingScene?: string;
|
billingScene?: string;
|
||||||
engineProvider?: string;
|
|
||||||
engineModelName?: string;
|
|
||||||
pricingVersionCode?: string;
|
|
||||||
providerCostStatus?: string;
|
|
||||||
providerCostIsEstimated?: boolean;
|
|
||||||
hasAttachment?: boolean;
|
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelPricingCategory = 'text' | 'image' | 'video';
|
|
||||||
export type ModelPricingBillingMode =
|
|
||||||
| 'text_token_tiered'
|
|
||||||
| 'image_per_output'
|
|
||||||
| 'image_input_output_tiered'
|
|
||||||
| 'video_token_rate';
|
|
||||||
export type ModelPricingRuleStatus = 'draft' | 'published' | 'disabled';
|
|
||||||
export type ModelPricingCalculatorVersion =
|
|
||||||
| 'text_token_tiered_v1'
|
|
||||||
| 'image_per_output_v1'
|
|
||||||
| 'image_input_output_tiered_v1'
|
|
||||||
| 'video_pixel_token_v1';
|
|
||||||
|
|
||||||
export interface ModelPricingRule {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
modelName: string;
|
|
||||||
modelCategory: ModelPricingCategory;
|
|
||||||
billingMode: ModelPricingBillingMode;
|
|
||||||
calculatorVersion: ModelPricingCalculatorVersion;
|
|
||||||
versionCode: string;
|
|
||||||
effectiveFrom: string;
|
|
||||||
effectiveTo?: string | null;
|
|
||||||
publishStatus: ModelPricingRuleStatus;
|
|
||||||
currency: string;
|
|
||||||
ruleSchemaVersion: number;
|
|
||||||
ruleContentHash: string;
|
|
||||||
ruleJson: Record<string, any>;
|
|
||||||
sourceUrl?: string | null;
|
|
||||||
sourceUpdatedAt?: string | null;
|
|
||||||
remark?: string | null;
|
|
||||||
referencedCount: number;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelPricingRulePayload {
|
|
||||||
provider: string;
|
|
||||||
model_name: string;
|
|
||||||
model_category: ModelPricingCategory;
|
|
||||||
billing_mode: ModelPricingBillingMode;
|
|
||||||
calculator_version: ModelPricingCalculatorVersion;
|
|
||||||
version_code: string;
|
|
||||||
effective_from: string;
|
|
||||||
effective_to?: string | null;
|
|
||||||
currency: string;
|
|
||||||
rule_schema_version: number;
|
|
||||||
rule_json: Record<string, any>;
|
|
||||||
source_url?: string | null;
|
|
||||||
source_updated_at?: string | null;
|
|
||||||
remark?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelPricingRuleListResponse {
|
|
||||||
items: ModelPricingRule[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelPricingPreviewResponse {
|
|
||||||
amount: string;
|
|
||||||
currency: string;
|
|
||||||
isEstimated: boolean;
|
|
||||||
selectedRate?: string | null;
|
|
||||||
usageSource: string;
|
|
||||||
breakdown: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 首页素材行业装修 ──────────────────────────────────────
|
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1412,3 +1381,48 @@ export interface PrivatePortraitSelectableAssetListOut {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Video Upscale ────────────────────────────────────────
|
||||||
|
|
||||||
|
export type VideoUpscaleProcessorKey =
|
||||||
|
| 'local_ffmpeg_crop_v1'
|
||||||
|
| 'volc_large_model_v1'
|
||||||
|
| 'volc_standard_v1'
|
||||||
|
| 'volc_professional_v1';
|
||||||
|
|
||||||
|
export interface VideoUpscaleResolutionRule {
|
||||||
|
targetResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
|
||||||
|
providerGenerationResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
|
||||||
|
processorKey: VideoUpscaleProcessorKey;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoUpscaleConfigData {
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
deleteSourceAfterSuccess: boolean;
|
||||||
|
rules: VideoUpscaleResolutionRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoUpscaleConfigOut {
|
||||||
|
id?: string | null;
|
||||||
|
key: string;
|
||||||
|
description?: string | null;
|
||||||
|
data: VideoUpscaleConfigData;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoUpscaleConfigSavePayload {
|
||||||
|
data: {
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
delete_source_after_success: boolean;
|
||||||
|
rules: Array<{
|
||||||
|
target_resolution: string;
|
||||||
|
provider_generation_resolution: string;
|
||||||
|
processor_key: VideoUpscaleProcessorKey;
|
||||||
|
enabled: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 降级方案:使用 textarea + execCommand
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const succeeded = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return succeeded;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
|
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
|
||||||
|
|
||||||
export function formatDate(iso: string | null | undefined): string {
|
export function formatDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '-';
|
if (!iso) return '-';
|
||||||
let s = iso.trim();
|
const s = iso.trim();
|
||||||
if (!s.includes('T')) s = s.replace(' ', 'T');
|
if (!s) return '-';
|
||||||
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
|
|
||||||
const dotIdx = s.indexOf('.');
|
// Parse the ISO string, handling timezone offset
|
||||||
if (dotIdx > 0) s = s.slice(0, dotIdx);
|
// Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
|
||||||
// Remove any trailing timezone info (backend now sends naive datetimes)
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
|
||||||
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
|
if (!m) return s.slice(0, 16).replace('T', ' ');
|
||||||
return s.replace('T', ' ').slice(0, 16);
|
|
||||||
|
const [, year, month, day, hour, min, sec, tz] = m;
|
||||||
|
// Build a Date in UTC
|
||||||
|
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
|
||||||
|
|
||||||
|
if (tz && tz !== 'Z') {
|
||||||
|
// Has explicit offset like +00:00 or +08:00 — already accounted for in the matched components
|
||||||
|
// We parsed HH:MM:SS as-is, which are in the given offset.
|
||||||
|
// Convert to UTC first by subtracting the offset
|
||||||
|
const sign = tz[0] === '+' ? 1 : -1;
|
||||||
|
const [oh, om] = tz.slice(1).split(':');
|
||||||
|
const offsetMin = sign * (+oh * 60 + +om);
|
||||||
|
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
|
||||||
|
const d = new Date(localMs);
|
||||||
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tz or Z: if Z it's UTC, if no tz it's naive (assume CST from backend)
|
||||||
|
const isUTC = tz === 'Z';
|
||||||
|
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
|
||||||
|
const d = new Date(localMs);
|
||||||
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : String(n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export interface GenerationStatusLike {
|
||||||
|
status?: string | null;
|
||||||
|
displayStatus?: string | null;
|
||||||
|
pipelineStage?: string | null;
|
||||||
|
shouldPoll?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
||||||
|
|
||||||
|
export interface GenerationUiState {
|
||||||
|
status: string;
|
||||||
|
displayStatus: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
effectiveKey: string;
|
||||||
|
label: string;
|
||||||
|
color: GenerationUiColor;
|
||||||
|
isActive: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
isFailure: boolean;
|
||||||
|
isTerminal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
|
||||||
|
const ACTIVE_PIPELINE_STAGES = new Set([
|
||||||
|
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
|
||||||
|
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
|
||||||
|
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
|
||||||
|
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
|
||||||
|
]);
|
||||||
|
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
||||||
|
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
||||||
|
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
pending: '待处理', optimizing: '提词处理中', prompt_optimized: '待生成', generating: '生成中',
|
||||||
|
queued: '已入队', preparing: '准备中', creating_provider_task: '创建供应商任务',
|
||||||
|
provider_result_staged: '供应商结果已暂存', waiting_remote: '等待供应商结果', polling: '轮询供应商结果',
|
||||||
|
result_ready: '远程结果已就绪', download_queued: '下载已入队', downloading: '下载中',
|
||||||
|
retry_waiting: '下载等待重试', recovery_inconsistent: '恢复证据异常', upscale_queued: '超分已入队',
|
||||||
|
upscale_processing: '本地超分处理中', upscale_polling: '轮询远程超分',
|
||||||
|
upscale_downloading: '下载超分结果', upscale_finalizing: '超分结果最终化',
|
||||||
|
upscale_retry_waiting: '超分等待重试', completed: '已完成', done: '已完成', timeout: '任务超时',
|
||||||
|
download_failed: '下载失败', upscale_failed: '超分失败', failed: '失败', deleted: '已删除',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
||||||
|
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'warning',
|
||||||
|
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
|
||||||
|
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
|
||||||
|
result_ready: 'processing', download_queued: 'processing', downloading: 'processing', retry_waiting: 'orange',
|
||||||
|
recovery_inconsistent: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
|
||||||
|
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
|
||||||
|
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
|
||||||
|
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||||
|
const firstMatching = (values: string[], keys: Set<string>): string => values.find((item) => keys.has(item)) || '';
|
||||||
|
|
||||||
|
export const getGenerationStageLabel = (key?: string | null): string => {
|
||||||
|
const normalized = normalize(key);
|
||||||
|
return LABELS[normalized] || normalized || '未知状态';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGenerationStatusColor = (key?: string | null): GenerationUiColor => {
|
||||||
|
const normalized = normalize(key);
|
||||||
|
return COLOR_MAP[normalized] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
|
||||||
|
const status = normalize(value.status);
|
||||||
|
const displayStatus = normalize(value.displayStatus);
|
||||||
|
const pipelineStage = normalize(value.pipelineStage);
|
||||||
|
const values = [pipelineStage, displayStatus, status].filter(Boolean);
|
||||||
|
const failureKey = firstMatching(values, FAILURE_KEYS);
|
||||||
|
const successKey = firstMatching(values, SUCCESS_KEYS);
|
||||||
|
const deletedKey = firstMatching(values, new Set(['deleted']));
|
||||||
|
const effectiveKey = failureKey || deletedKey || successKey || pipelineStage || displayStatus || status || 'pending';
|
||||||
|
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
||||||
|
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
||||||
|
const isActive = typeof value.shouldPoll === 'boolean'
|
||||||
|
? value.shouldPoll
|
||||||
|
: (!isFailure && !isSuccess && effectiveKey !== 'deleted' && (ACTIVE_PIPELINE_STAGES.has(pipelineStage) || ACTIVE_STATUS_KEYS.has(status) || ACTIVE_STATUS_KEYS.has(displayStatus)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status, displayStatus, pipelineStage, effectiveKey,
|
||||||
|
label: getGenerationStageLabel(effectiveKey),
|
||||||
|
color: getGenerationStatusColor(effectiveKey),
|
||||||
|
isActive, isSuccess, isFailure,
|
||||||
|
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isGenerationActive = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isActive;
|
||||||
|
export const isGenerationSuccess = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isSuccess;
|
||||||
|
export const isGenerationFailure = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isFailure;
|
||||||
|
export const isGenerationTerminal = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isTerminal;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export interface ShotStatusMeta {
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShotStatusMap = Record<string, Omit<ShotStatusMeta, 'key'>>;
|
||||||
|
|
||||||
|
const TASK_STATUS_MAP: ShotStatusMap = {
|
||||||
|
pending_analysis: { text: '等待分析', color: 'default', active: true, terminal: false },
|
||||||
|
analyzing: { text: '分析中', color: 'processing', active: true, terminal: false },
|
||||||
|
analysis_completed: { text: '分析完成', color: 'blue', active: false, terminal: false },
|
||||||
|
analysis_failed: { text: '分析失败', color: 'error', active: false, terminal: true },
|
||||||
|
splitting: { text: '拆镜中', color: 'processing', active: true, terminal: false },
|
||||||
|
split_completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
|
||||||
|
partial_failed: { text: '部分失败', color: 'warning', active: false, terminal: true },
|
||||||
|
failed: { text: '失败', color: 'error', active: false, terminal: true },
|
||||||
|
deleted: { text: '已删除', color: 'default', active: false, terminal: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANALYSIS_STATUS_MAP: ShotStatusMap = {
|
||||||
|
not_required: { text: '无需分析', color: 'default', active: false, terminal: true },
|
||||||
|
pending: { text: '等待分析', color: 'default', active: true, terminal: false },
|
||||||
|
processing: { text: '分析中', color: 'processing', active: true, terminal: false },
|
||||||
|
completed: { text: '分析完成', color: 'success', active: false, terminal: true },
|
||||||
|
failed: { text: '分析失败', color: 'error', active: false, terminal: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPLIT_STATUS_MAP: ShotStatusMap = {
|
||||||
|
none: { text: '未拆镜', color: 'default', active: false, terminal: true },
|
||||||
|
pending: { text: '等待拆镜', color: 'default', active: true, terminal: false },
|
||||||
|
processing: { text: '拆镜中', color: 'processing', active: true, terminal: false },
|
||||||
|
retry_waiting: { text: '等待拆镜重试', color: 'orange', active: true, terminal: false },
|
||||||
|
completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
|
||||||
|
failed: { text: '拆镜失败', color: 'error', active: false, terminal: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPLICATE_STATUS_MAP: ShotStatusMap = {
|
||||||
|
not_started: { text: '未复刻', color: 'default', active: false, terminal: true },
|
||||||
|
project_created: { text: '已创建项目', color: 'processing', active: true, terminal: false },
|
||||||
|
pending: { text: '等待复刻', color: 'default', active: true, terminal: false },
|
||||||
|
waiting_user: { text: '等待用户操作', color: 'processing', active: true, terminal: false },
|
||||||
|
processing: { text: '复刻中', color: 'processing', active: true, terminal: false },
|
||||||
|
completed: { text: '复刻完成', color: 'success', active: false, terminal: true },
|
||||||
|
failed: { text: '复刻失败', color: 'error', active: false, terminal: true },
|
||||||
|
cancelled: { text: '已取消', color: 'default', active: false, terminal: true },
|
||||||
|
canceled: { text: '已取消', color: 'default', active: false, terminal: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const resolveMeta = (status: string | null | undefined, map: ShotStatusMap, fallback = '未知状态'): ShotStatusMeta => {
|
||||||
|
const key = normalize(status);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...(map[key] || { text: key || fallback, color: 'default', active: false, terminal: false }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShotTaskStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, TASK_STATUS_MAP);
|
||||||
|
export const getShotAnalysisStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, ANALYSIS_STATUS_MAP);
|
||||||
|
export const getShotSplitStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, SPLIT_STATUS_MAP);
|
||||||
|
export const getShotReplicateStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, REPLICATE_STATUS_MAP);
|
||||||
|
|
||||||
|
export const getShotStatusMeta = (status?: string | null): ShotStatusMeta => {
|
||||||
|
const key = normalize(status);
|
||||||
|
return getShotTaskStatusMeta(key).text !== key
|
||||||
|
? getShotTaskStatusMeta(key)
|
||||||
|
: (ANALYSIS_STATUS_MAP[key]
|
||||||
|
? getShotAnalysisStatusMeta(key)
|
||||||
|
: (SPLIT_STATUS_MAP[key]
|
||||||
|
? getShotSplitStatusMeta(key)
|
||||||
|
: getShotReplicateStatusMeta(key)));
|
||||||
|
};
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/modelpricing/pricingruleform.tsx","./src/components/modelpricing/pricingrulepreview.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodelpricingrules.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||||
@@ -43,6 +43,9 @@ CAPTCHA_ENABLED=true
|
|||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS=["*"]
|
CORS_ORIGINS=["*"]
|
||||||
|
|
||||||
|
# Base URL (用于 favicon、回调地址等)
|
||||||
|
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
|
||||||
|
|
||||||
# RESOURCE
|
# RESOURCE
|
||||||
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
|
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
|
||||||
RESOURCE_SIGN_EXPIRE_SECONDS=86400
|
RESOURCE_SIGN_EXPIRE_SECONDS=86400
|
||||||
@@ -58,6 +61,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
|
|||||||
VIDEO_COVER_FORMAT=png
|
VIDEO_COVER_FORMAT=png
|
||||||
|
|
||||||
# VOLC
|
# VOLC
|
||||||
|
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
|
||||||
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||||
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,6 @@ CAPTCHA_ENABLED=true
|
|||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
|
# Base URL (用于 favicon、回调地址等)
|
||||||
|
BASE_URL=https://your-domain.com
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
"""add model pricing rules and credit pricing snapshots
|
|
||||||
|
|
||||||
Revision ID: 1c93b40133f0
|
|
||||||
Revises: 2026070902
|
|
||||||
Create Date: 2026-07-10 15:10:44.983521
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "1c93b40133f0"
|
|
||||||
down_revision: Union[str, None] = "2026070902"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
FK_CREDIT_RECORD_PRICING_RULE = (
|
|
||||||
"fk_credit_records_pricing_rule_id_model_pricing_rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_type() -> sa.types.TypeEngine:
|
|
||||||
"""Use JSONB on PostgreSQL and JSON on other supported development DBs."""
|
|
||||||
return sa.JSON().with_variant(
|
|
||||||
postgresql.JSONB(astext_type=sa.Text()),
|
|
||||||
"postgresql",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 1. Versioned model pricing rules.
|
|
||||||
op.create_table(
|
|
||||||
"model_pricing_rules",
|
|
||||||
sa.Column("id", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("provider", sa.String(length=32), nullable=False),
|
|
||||||
sa.Column("model_name", sa.String(length=128), nullable=False),
|
|
||||||
sa.Column("model_category", sa.String(length=16), nullable=False),
|
|
||||||
sa.Column("billing_mode", sa.String(length=48), nullable=False),
|
|
||||||
sa.Column("calculator_version", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("version_code", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("publish_status", sa.String(length=16), nullable=False),
|
|
||||||
sa.Column("currency", sa.String(length=8), nullable=False),
|
|
||||||
sa.Column("rule_schema_version", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("rule_json", _json_type(), nullable=False),
|
|
||||||
sa.Column("rule_content_hash", sa.String(length=64), nullable=False),
|
|
||||||
sa.Column("source_url", sa.Text(), nullable=True),
|
|
||||||
sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("remark", sa.Text(), nullable=True),
|
|
||||||
sa.Column("created_by", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column("updated_by", sa.String(length=32), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"created_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
# A disabled future rule is represented by an empty interval where
|
|
||||||
# effective_to == effective_from, so equality must be allowed.
|
|
||||||
sa.CheckConstraint(
|
|
||||||
"effective_to IS NULL OR effective_to >= effective_from",
|
|
||||||
name="ck_model_pricing_rules_effective_range",
|
|
||||||
),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_index(
|
|
||||||
"uq_model_pricing_rules_provider_model_version",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["provider", "model_name", "version_code"],
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_resolve",
|
|
||||||
"model_pricing_rules",
|
|
||||||
[
|
|
||||||
"provider",
|
|
||||||
"model_name",
|
|
||||||
"publish_status",
|
|
||||||
"effective_from",
|
|
||||||
"effective_to",
|
|
||||||
],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_category_status",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["model_category", "publish_status"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_provider",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["provider"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_model_name",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["model_name"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_model_category",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["model_category"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_billing_mode",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["billing_mode"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_calculator_version",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["calculator_version"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_effective_from",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["effective_from"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_effective_to",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["effective_to"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_publish_status",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["publish_status"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_model_pricing_rules_rule_content_hash",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["rule_content_hash"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Bind provider callbacks to the exact billing attempt.
|
|
||||||
op.add_column(
|
|
||||||
"chat_generation_tasks",
|
|
||||||
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_chat_generation_tasks_current_billing_attempt_no",
|
|
||||||
"chat_generation_tasks",
|
|
||||||
["current_billing_attempt_no"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Immutable pricing, usage, attachment, and output snapshots.
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_rule_id", sa.String(length=32), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_version_code", sa.String(length=64), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_billing_mode", sa.String(length=48), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"pricing_calculator_version",
|
|
||||||
sa.String(length=64),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_usage_source", sa.String(length=32), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_reference_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_effective_from", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_effective_to", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_snapshot_schema_version", sa.Integer(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_snapshot_hash", sa.String(length=64), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("provider_cost_currency", sa.String(length=8), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"provider_cost_amount",
|
|
||||||
sa.Numeric(precision=20, scale=8),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("provider_cost_status", sa.String(length=32), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"provider_cost_calculated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"provider_cost_finalized_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"provider_usage_primary",
|
|
||||||
sa.Boolean(),
|
|
||||||
server_default=sa.text("true"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"provider_cost_is_estimated",
|
|
||||||
sa.Boolean(),
|
|
||||||
server_default=sa.text("false"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_image_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_video_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_audio_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_total_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_video_duration_seconds",
|
|
||||||
sa.Numeric(precision=20, scale=6),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"attachment_audio_duration_seconds",
|
|
||||||
sa.Numeric(precision=20, scale=6),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"requested_output_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"generated_image_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"generated_video_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column(
|
|
||||||
"generated_total_count",
|
|
||||||
sa.Integer(),
|
|
||||||
server_default=sa.text("0"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("pricing_snapshot_json", _json_type(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("usage_snapshot_json", _json_type(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("attachment_snapshot_json", _json_type(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"credit_records",
|
|
||||||
sa.Column("generation_snapshot_json", _json_type(), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_rule_id",
|
|
||||||
"credit_records",
|
|
||||||
["pricing_rule_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_version_code",
|
|
||||||
"credit_records",
|
|
||||||
["pricing_version_code"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_usage_source",
|
|
||||||
"credit_records",
|
|
||||||
["pricing_usage_source"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_reference_at",
|
|
||||||
"credit_records",
|
|
||||||
["pricing_reference_at"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_provider_cost_status",
|
|
||||||
"credit_records",
|
|
||||||
["provider_cost_status"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_status_time",
|
|
||||||
"credit_records",
|
|
||||||
["provider_cost_status", "created_at"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_model_time",
|
|
||||||
"credit_records",
|
|
||||||
["engine_provider", "engine_model_name", "pricing_reference_at"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_credit_records_pricing_version",
|
|
||||||
"credit_records",
|
|
||||||
["pricing_version_code", "pricing_rule_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
FK_CREDIT_RECORD_PRICING_RULE,
|
|
||||||
"credit_records",
|
|
||||||
"model_pricing_rules",
|
|
||||||
["pricing_rule_id"],
|
|
||||||
["id"],
|
|
||||||
ondelete="RESTRICT",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Lock the engine and billing attempt for legacy project generations.
|
|
||||||
op.add_column(
|
|
||||||
"generation_records",
|
|
||||||
sa.Column("engine_id", sa.String(length=32), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"generation_records",
|
|
||||||
sa.Column("engine_snapshot_json", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"generation_records",
|
|
||||||
sa.Column("provider_response_json", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"generation_records",
|
|
||||||
sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_generation_records_engine_id",
|
|
||||||
"generation_records",
|
|
||||||
["engine_id"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_generation_records_current_billing_attempt_no",
|
|
||||||
"generation_records",
|
|
||||||
["current_billing_attempt_no"],
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Reverse legacy generation record extensions.
|
|
||||||
op.drop_index(
|
|
||||||
"ix_generation_records_current_billing_attempt_no",
|
|
||||||
table_name="generation_records",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_generation_records_engine_id",
|
|
||||||
table_name="generation_records",
|
|
||||||
)
|
|
||||||
op.drop_column("generation_records", "current_billing_attempt_no")
|
|
||||||
op.drop_column("generation_records", "provider_response_json")
|
|
||||||
op.drop_column("generation_records", "engine_snapshot_json")
|
|
||||||
op.drop_column("generation_records", "engine_id")
|
|
||||||
|
|
||||||
# Reverse credit pricing snapshots before dropping the referenced rule table.
|
|
||||||
op.drop_constraint(
|
|
||||||
FK_CREDIT_RECORD_PRICING_RULE,
|
|
||||||
"credit_records",
|
|
||||||
type_="foreignkey",
|
|
||||||
)
|
|
||||||
op.drop_index("ix_credit_records_pricing_version", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_model_time", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_status_time", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_provider_cost_status", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_reference_at", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_usage_source", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_version_code", table_name="credit_records")
|
|
||||||
op.drop_index("ix_credit_records_pricing_rule_id", table_name="credit_records")
|
|
||||||
|
|
||||||
op.drop_column("credit_records", "generation_snapshot_json")
|
|
||||||
op.drop_column("credit_records", "attachment_snapshot_json")
|
|
||||||
op.drop_column("credit_records", "usage_snapshot_json")
|
|
||||||
op.drop_column("credit_records", "pricing_snapshot_json")
|
|
||||||
op.drop_column("credit_records", "generated_total_count")
|
|
||||||
op.drop_column("credit_records", "generated_video_count")
|
|
||||||
op.drop_column("credit_records", "generated_image_count")
|
|
||||||
op.drop_column("credit_records", "requested_output_count")
|
|
||||||
op.drop_column("credit_records", "attachment_audio_duration_seconds")
|
|
||||||
op.drop_column("credit_records", "attachment_video_duration_seconds")
|
|
||||||
op.drop_column("credit_records", "attachment_total_count")
|
|
||||||
op.drop_column("credit_records", "attachment_audio_count")
|
|
||||||
op.drop_column("credit_records", "attachment_video_count")
|
|
||||||
op.drop_column("credit_records", "attachment_image_count")
|
|
||||||
op.drop_column("credit_records", "provider_cost_is_estimated")
|
|
||||||
op.drop_column("credit_records", "provider_usage_primary")
|
|
||||||
op.drop_column("credit_records", "provider_cost_finalized_at")
|
|
||||||
op.drop_column("credit_records", "provider_cost_calculated_at")
|
|
||||||
op.drop_column("credit_records", "provider_cost_status")
|
|
||||||
op.drop_column("credit_records", "provider_cost_amount")
|
|
||||||
op.drop_column("credit_records", "provider_cost_currency")
|
|
||||||
op.drop_column("credit_records", "pricing_snapshot_hash")
|
|
||||||
op.drop_column("credit_records", "pricing_snapshot_schema_version")
|
|
||||||
op.drop_column("credit_records", "pricing_effective_to")
|
|
||||||
op.drop_column("credit_records", "pricing_effective_from")
|
|
||||||
op.drop_column("credit_records", "pricing_reference_at")
|
|
||||||
op.drop_column("credit_records", "pricing_usage_source")
|
|
||||||
op.drop_column("credit_records", "pricing_calculator_version")
|
|
||||||
op.drop_column("credit_records", "pricing_billing_mode")
|
|
||||||
op.drop_column("credit_records", "pricing_version_code")
|
|
||||||
op.drop_column("credit_records", "pricing_rule_id")
|
|
||||||
|
|
||||||
# Reverse exact billing-attempt binding.
|
|
||||||
op.drop_index(
|
|
||||||
"ix_chat_generation_tasks_current_billing_attempt_no",
|
|
||||||
table_name="chat_generation_tasks",
|
|
||||||
)
|
|
||||||
op.drop_column("chat_generation_tasks", "current_billing_attempt_no")
|
|
||||||
|
|
||||||
# Reverse pricing-rule storage.
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_rule_content_hash",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_publish_status",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_effective_to",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_effective_from",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_calculator_version",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_billing_mode",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_model_category",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_model_name",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_provider",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_category_status",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"ix_model_pricing_rules_resolve",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_index(
|
|
||||||
"uq_model_pricing_rules_provider_model_version",
|
|
||||||
table_name="model_pricing_rules",
|
|
||||||
)
|
|
||||||
op.drop_table("model_pricing_rules")
|
|
||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
"""repair llm billing optimize idempotency
|
||||||
|
|
||||||
|
Revision ID: 20da1d353914
|
||||||
|
Revises: 6a3ea8d0b4c8
|
||||||
|
Create Date: 2026-07-24 13:35:48.513710
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20da1d353914"
|
||||||
|
down_revision: Union[str, None] = "6a3ea8d0b4c8"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT_USAGE_COLUMN = "prompt_usage_snapshot_json"
|
||||||
|
|
||||||
|
_INDEX_SPECS: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"name": "uq_credit_records_user_refund_target",
|
||||||
|
"table": "credit_records",
|
||||||
|
"columns": ("user_id", "refund_for_biz_key"),
|
||||||
|
"where_sql": "type = 'refund' AND refund_for_biz_key IS NOT NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, refund_for_biz_key)",
|
||||||
|
"type",
|
||||||
|
"refund",
|
||||||
|
"refund_for_biz_key is not null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
refund_for_biz_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM credit_records
|
||||||
|
WHERE type = 'refund'
|
||||||
|
AND refund_for_biz_key IS NOT NULL
|
||||||
|
GROUP BY user_id, refund_for_biz_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, refund_for_biz_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uq_genrec_user_idempotency_active",
|
||||||
|
"table": "generation_records",
|
||||||
|
"columns": ("user_id", "idempotency_key"),
|
||||||
|
"where_sql": "idempotency_key IS NOT NULL AND deleted_at IS NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, idempotency_key)",
|
||||||
|
"idempotency_key is not null",
|
||||||
|
"deleted_at is null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
idempotency_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM generation_records
|
||||||
|
WHERE idempotency_key IS NOT NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
GROUP BY user_id, idempotency_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, idempotency_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uq_token_usage_user_biz_key",
|
||||||
|
"table": "token_usage",
|
||||||
|
"columns": ("user_id", "biz_key"),
|
||||||
|
"where_sql": "biz_key IS NOT NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, biz_key)",
|
||||||
|
"biz_key is not null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
biz_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM token_usage
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND biz_key IS NOT NULL
|
||||||
|
GROUP BY user_id, biz_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, biz_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind() -> Connection:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name != "postgresql":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Migration 20da1d353914 requires PostgreSQL; "
|
||||||
|
f"current dialect is {bind.dialect.name!r}."
|
||||||
|
)
|
||||||
|
return bind
|
||||||
|
|
||||||
|
|
||||||
|
def _current_schema(bind: Connection) -> str:
|
||||||
|
schema = bind.execute(sa.text("SELECT current_schema()")).scalar_one_or_none()
|
||||||
|
if not schema:
|
||||||
|
raise RuntimeError("Unable to resolve PostgreSQL current_schema().")
|
||||||
|
return str(schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_table(bind: Connection, schema: str, table_name: str) -> None:
|
||||||
|
exists = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = :schema
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"schema": schema, "table_name": table_name},
|
||||||
|
).scalar_one()
|
||||||
|
if not bool(exists):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Required table {schema}.{table_name} does not exist; "
|
||||||
|
"refusing to apply migration on an unexpected schema baseline."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
table_name: str,
|
||||||
|
column_name: str,
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = :schema
|
||||||
|
AND table_name = :table_name
|
||||||
|
AND column_name = :column_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"schema": schema,
|
||||||
|
"table_name": table_name,
|
||||||
|
"column_name": column_name,
|
||||||
|
},
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_info(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
index_name: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
table_rel.relname AS table_name,
|
||||||
|
index_meta.indisvalid AS is_valid,
|
||||||
|
index_meta.indisunique AS is_unique,
|
||||||
|
pg_get_indexdef(index_rel.oid) AS index_definition
|
||||||
|
FROM pg_class AS index_rel
|
||||||
|
JOIN pg_namespace AS namespace_rel
|
||||||
|
ON namespace_rel.oid = index_rel.relnamespace
|
||||||
|
JOIN pg_index AS index_meta
|
||||||
|
ON index_meta.indexrelid = index_rel.oid
|
||||||
|
JOIN pg_class AS table_rel
|
||||||
|
ON table_rel.oid = index_meta.indrelid
|
||||||
|
WHERE namespace_rel.nspname = :schema
|
||||||
|
AND index_rel.relname = :index_name
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"schema": schema, "index_name": index_name},
|
||||||
|
).mappings().one_or_none()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_index_definition(value: str) -> str:
|
||||||
|
normalized = value.lower().replace('"', "")
|
||||||
|
normalized = re.sub(r"::[a-z_ ]+(?:\[\])?", "", normalized)
|
||||||
|
normalized = re.sub(r"[()]", lambda match: match.group(0), normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_existing_index_matches(spec: dict[str, Any], info: dict[str, Any]) -> None:
|
||||||
|
if str(info["table_name"]) != str(spec["table"]):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists on table {info['table_name']}, "
|
||||||
|
f"expected table {spec['table']}."
|
||||||
|
)
|
||||||
|
if not bool(info["is_unique"]):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists but is not UNIQUE."
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = _normalize_index_definition(str(info["index_definition"] or ""))
|
||||||
|
missing = [
|
||||||
|
fragment
|
||||||
|
for fragment in spec["definition_fragments"]
|
||||||
|
if fragment not in definition
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists with an unexpected definition; "
|
||||||
|
f"missing expected fragments: {missing}. Actual definition: "
|
||||||
|
f"{info['index_definition']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_duplicates(bind: Connection, spec: dict[str, Any]) -> None:
|
||||||
|
rows = bind.execute(sa.text(spec["duplicate_sql"])).mappings().all()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
samples = "; ".join(
|
||||||
|
", ".join(f"{key}={value!r}" for key, value in row.items())
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot create unique index {spec['name']}: duplicate historical data "
|
||||||
|
f"exists. Resolve the conflicts first. Samples: {samples}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(bind: Connection, value: str) -> str:
|
||||||
|
return bind.dialect.identifier_preparer.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_missing_indexes_concurrently(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
) -> None:
|
||||||
|
missing_specs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for spec in _INDEX_SPECS:
|
||||||
|
_require_table(bind, schema, str(spec["table"]))
|
||||||
|
info = _index_info(bind, schema, str(spec["name"]))
|
||||||
|
if info is None:
|
||||||
|
_assert_no_duplicates(bind, spec)
|
||||||
|
missing_specs.append(spec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bool(info["is_valid"]):
|
||||||
|
_assert_existing_index_matches(spec, info)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A failed CREATE INDEX CONCURRENTLY may leave an invalid index behind.
|
||||||
|
# Remove only the invalid index with this migration-owned name, then retry.
|
||||||
|
missing_specs.append(spec)
|
||||||
|
|
||||||
|
if not missing_specs:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = op.get_context()
|
||||||
|
with context.autocommit_block():
|
||||||
|
for spec in missing_specs:
|
||||||
|
current = _index_info(bind, schema, str(spec["name"]))
|
||||||
|
quoted_index = _quote_identifier(bind, str(spec["name"]))
|
||||||
|
quoted_schema = _quote_identifier(bind, schema)
|
||||||
|
if current is not None:
|
||||||
|
if bool(current["is_valid"]):
|
||||||
|
_assert_existing_index_matches(spec, current)
|
||||||
|
continue
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"DROP INDEX CONCURRENTLY IF EXISTS "
|
||||||
|
f"{quoted_schema}.{quoted_index}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
quoted_table = _quote_identifier(bind, str(spec["table"]))
|
||||||
|
quoted_columns = ", ".join(
|
||||||
|
_quote_identifier(bind, str(column))
|
||||||
|
for column in spec["columns"]
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"CREATE UNIQUE INDEX CONCURRENTLY {quoted_index} "
|
||||||
|
f"ON {quoted_schema}.{quoted_table} ({quoted_columns}) "
|
||||||
|
f"WHERE {spec['where_sql']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_indexes_concurrently(bind: Connection, schema: str) -> None:
|
||||||
|
existing_names = [
|
||||||
|
str(spec["name"])
|
||||||
|
for spec in _INDEX_SPECS
|
||||||
|
if _index_info(bind, schema, str(spec["name"])) is not None
|
||||||
|
]
|
||||||
|
if not existing_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
quoted_schema = _quote_identifier(bind, schema)
|
||||||
|
context = op.get_context()
|
||||||
|
with context.autocommit_block():
|
||||||
|
for index_name in existing_names:
|
||||||
|
quoted_index = _quote_identifier(bind, index_name)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"DROP INDEX CONCURRENTLY IF EXISTS "
|
||||||
|
f"{quoted_schema}.{quoted_index}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = _bind()
|
||||||
|
schema = _current_schema(bind)
|
||||||
|
|
||||||
|
_require_table(bind, schema, "generation_records")
|
||||||
|
if not _column_exists(
|
||||||
|
bind,
|
||||||
|
schema,
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
):
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column(_PROMPT_USAGE_COLUMN, sa.Text(), nullable=True),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_missing_indexes_concurrently(bind, schema)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = _bind()
|
||||||
|
schema = _current_schema(bind)
|
||||||
|
|
||||||
|
_drop_indexes_concurrently(bind, schema)
|
||||||
|
|
||||||
|
_require_table(bind, schema, "generation_records")
|
||||||
|
if _column_exists(
|
||||||
|
bind,
|
||||||
|
schema,
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
):
|
||||||
|
op.drop_column(
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""add video upscale pipeline
|
||||||
|
|
||||||
|
Revision ID: 3f47680a71d0
|
||||||
|
Revises: abae3e1c70f7
|
||||||
|
Create Date: 2026-07-16 11:08:16.449960
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3f47680a71d0"
|
||||||
|
down_revision: Union[str, None] = "abae3e1c70f7"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column(
|
||||||
|
"video_upscale_enabled_snapshot",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column(
|
||||||
|
"video_upscale_enabled_snapshot",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column("pipeline_stage", sa.String(length=48), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_generation_records_pipeline_stage",
|
||||||
|
"generation_records",
|
||||||
|
["pipeline_stage"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"video_upscale_tasks",
|
||||||
|
sa.Column("id", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("chat_generation_task_id", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("generation_record_id", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
|
||||||
|
sa.Column("stage", sa.String(length=48), server_default="upscale_queued", nullable=False),
|
||||||
|
sa.Column("processor_key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("failure_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("manual_retry_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_local_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
|
||||||
|
sa.Column("source_width", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_height", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_duration_seconds", sa.Float(), nullable=True),
|
||||||
|
sa.Column("source_deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_delete_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_remote_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_remote_url_signed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_remote_url_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_remote_url_last_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_remote_url_probe_status", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("input_source_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("input_source_fallback_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("target_width", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("target_height", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("effective_target_width", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("effective_target_height", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("provider_task_id", sa.String(length=160), nullable=True),
|
||||||
|
sa.Column("provider_request_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("provider_response_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("provider_output_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("provider_output_url_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("provider_submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("final_local_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("final_resource_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("final_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
|
||||||
|
sa.Column("celery_task_id", sa.String(length=160), nullable=True),
|
||||||
|
sa.Column("lease_token", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
|
||||||
|
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
|
||||||
|
name="ck_video_upscale_tasks_exactly_one_owner",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["chat_generation_task_id"],
|
||||||
|
["chat_generation_tasks.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["generation_record_id"],
|
||||||
|
["generation_records.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_video_upscale_tasks_provider_task_id",
|
||||||
|
"video_upscale_tasks",
|
||||||
|
["provider_task_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_video_upscale_tasks_status_lease",
|
||||||
|
"video_upscale_tasks",
|
||||||
|
["status", "lease_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_video_upscale_tasks_status_next_retry",
|
||||||
|
"video_upscale_tasks",
|
||||||
|
["status", "next_retry_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_video_upscale_tasks_chat_task",
|
||||||
|
"video_upscale_tasks",
|
||||||
|
["chat_generation_task_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_video_upscale_tasks_generation_record",
|
||||||
|
"video_upscale_tasks",
|
||||||
|
["generation_record_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_video_upscale_tasks_generation_record", table_name="video_upscale_tasks")
|
||||||
|
op.drop_index("uq_video_upscale_tasks_chat_task", table_name="video_upscale_tasks")
|
||||||
|
op.drop_index("idx_video_upscale_tasks_status_next_retry", table_name="video_upscale_tasks")
|
||||||
|
op.drop_index("idx_video_upscale_tasks_status_lease", table_name="video_upscale_tasks")
|
||||||
|
op.drop_index("idx_video_upscale_tasks_provider_task_id", table_name="video_upscale_tasks")
|
||||||
|
op.drop_table("video_upscale_tasks")
|
||||||
|
|
||||||
|
op.drop_index("ix_generation_records_pipeline_stage", table_name="generation_records")
|
||||||
|
op.drop_column("generation_records", "pipeline_stage")
|
||||||
|
op.drop_column("generation_records", "video_upscale_snapshot_json")
|
||||||
|
op.drop_column("generation_records", "video_upscale_enabled_snapshot")
|
||||||
|
op.drop_column("generation_records", "provider_generation_resolution")
|
||||||
|
|
||||||
|
op.drop_column("chat_generation_tasks", "video_upscale_snapshot_json")
|
||||||
|
op.drop_column("chat_generation_tasks", "video_upscale_enabled_snapshot")
|
||||||
|
op.drop_column("chat_generation_tasks", "provider_generation_resolution")
|
||||||
+773
@@ -0,0 +1,773 @@
|
|||||||
|
"""repair generation record frozen config
|
||||||
|
|
||||||
|
Revision ID: 6a3ea8d0b4c8
|
||||||
|
Revises: 7cf645f7c418
|
||||||
|
Create Date: 2026-07-23 14:01:54.109369
|
||||||
|
修复GenerationRecord相关数据,并备份修改数据
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "6a3ea8d0b4c8"
|
||||||
|
down_revision: Union[str, None] = "7cf645f7c418"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
REPAIR_KEY = "repair_generation_record_frozen_config_20260723"
|
||||||
|
BACKUP_TABLE = "generation_records_repair_backup_20260723"
|
||||||
|
|
||||||
|
|
||||||
|
_TARGET_CONDITION = """
|
||||||
|
gr.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND gr.status = 'prompt_optimized'
|
||||||
|
AND gr.gen_type IN ('video', 'image')
|
||||||
|
AND (
|
||||||
|
gr.engine_id IS NULL
|
||||||
|
OR gr.engine_id = ''
|
||||||
|
OR gr.engine_snapshot_json IS NULL
|
||||||
|
OR gr.engine_snapshot_json = ''
|
||||||
|
OR gr.engine_snapshot_json IS NOT JSON
|
||||||
|
OR (
|
||||||
|
gr.gen_type = 'video'
|
||||||
|
AND (
|
||||||
|
gr.duration IS NULL
|
||||||
|
OR gr.aspect_ratio IS NULL
|
||||||
|
OR gr.aspect_ratio = ''
|
||||||
|
OR gr.resolution IS NULL
|
||||||
|
OR gr.resolution = ''
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
gr.gen_type = 'image'
|
||||||
|
AND (
|
||||||
|
gr.image_size IS NULL
|
||||||
|
OR gr.image_size = ''
|
||||||
|
OR gr.image_proportion IS NULL
|
||||||
|
OR gr.image_proportion = ''
|
||||||
|
OR gr.image_px IS NULL
|
||||||
|
OR gr.image_px = ''
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_CREATE_BACKUP_TABLE_SQL = f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {BACKUP_TABLE} (
|
||||||
|
repair_key VARCHAR(96) NOT NULL,
|
||||||
|
record_id VARCHAR(32) NOT NULL,
|
||||||
|
gen_type VARCHAR(16),
|
||||||
|
old_engine_id VARCHAR(32),
|
||||||
|
old_engine_snapshot_json TEXT,
|
||||||
|
old_duration INTEGER,
|
||||||
|
old_aspect_ratio VARCHAR(8),
|
||||||
|
old_resolution VARCHAR(8),
|
||||||
|
old_provider_generation_resolution VARCHAR(16),
|
||||||
|
old_video_upscale_enabled_snapshot BOOLEAN,
|
||||||
|
old_video_upscale_snapshot_json TEXT,
|
||||||
|
old_image_size VARCHAR(8),
|
||||||
|
old_image_proportion VARCHAR(8),
|
||||||
|
old_image_px VARCHAR(10),
|
||||||
|
old_include_media_references BOOLEAN,
|
||||||
|
old_updated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
backed_up_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (repair_key, record_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_INSERT_BACKUP_SQL = f"""
|
||||||
|
INSERT INTO {BACKUP_TABLE} (
|
||||||
|
repair_key,
|
||||||
|
record_id,
|
||||||
|
gen_type,
|
||||||
|
old_engine_id,
|
||||||
|
old_engine_snapshot_json,
|
||||||
|
old_duration,
|
||||||
|
old_aspect_ratio,
|
||||||
|
old_resolution,
|
||||||
|
old_provider_generation_resolution,
|
||||||
|
old_video_upscale_enabled_snapshot,
|
||||||
|
old_video_upscale_snapshot_json,
|
||||||
|
old_image_size,
|
||||||
|
old_image_proportion,
|
||||||
|
old_image_px,
|
||||||
|
old_include_media_references,
|
||||||
|
old_updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
:repair_key,
|
||||||
|
gr.id,
|
||||||
|
gr.gen_type,
|
||||||
|
gr.engine_id,
|
||||||
|
gr.engine_snapshot_json,
|
||||||
|
gr.duration,
|
||||||
|
gr.aspect_ratio,
|
||||||
|
gr.resolution,
|
||||||
|
gr.provider_generation_resolution,
|
||||||
|
gr.video_upscale_enabled_snapshot,
|
||||||
|
gr.video_upscale_snapshot_json,
|
||||||
|
gr.image_size,
|
||||||
|
gr.image_proportion,
|
||||||
|
gr.image_px,
|
||||||
|
gr.include_media_references,
|
||||||
|
gr.updated_at
|
||||||
|
FROM generation_records gr
|
||||||
|
JOIN projects p ON p.id = gr.project_id
|
||||||
|
WHERE {_TARGET_CONDITION}
|
||||||
|
ON CONFLICT (repair_key, record_id) DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# PostgreSQL JSON helper snippets used inside SQL expressions. These avoid calling
|
||||||
|
# jsonb_array_elements_text/jsonb_object_keys on malformed JSON or the wrong JSON type.
|
||||||
|
_VIDEO_RATIOS_JSON = """
|
||||||
|
CASE
|
||||||
|
WHEN ve.supported_ratios IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ve.supported_ratios::jsonb) = 'array'
|
||||||
|
THEN ve.supported_ratios::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
_VIDEO_RESOLUTIONS_JSON = """
|
||||||
|
CASE
|
||||||
|
WHEN ve.supported_resolutions IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ve.supported_resolutions::jsonb) = 'array'
|
||||||
|
THEN ve.supported_resolutions::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
_VIDEO_DURATIONS_JSON = """
|
||||||
|
CASE
|
||||||
|
WHEN ve.supported_durations IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ve.supported_durations::jsonb) = 'array'
|
||||||
|
THEN ve.supported_durations::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IMAGE_SUPPORTED_MODELS_JSON = """
|
||||||
|
CASE
|
||||||
|
WHEN px_resolved.supported_models IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(px_resolved.supported_models::jsonb) = 'array'
|
||||||
|
THEN px_resolved.supported_models::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IMAGE_SUPPORTED_SIZES_JSON_IE = """
|
||||||
|
CASE
|
||||||
|
WHEN ie.supported_sizes IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
|
||||||
|
THEN ie.supported_sizes::jsonb
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IMAGE_SUPPORTED_SIZES_JSON_ES = """
|
||||||
|
CASE
|
||||||
|
WHEN ie.supported_sizes IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
|
||||||
|
THEN ie.supported_sizes::jsonb
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_REPAIR_VIDEO_SQL = f"""
|
||||||
|
WITH target_records AS (
|
||||||
|
SELECT gr.*
|
||||||
|
FROM generation_records gr
|
||||||
|
JOIN projects p ON p.id = gr.project_id
|
||||||
|
WHERE {_TARGET_CONDITION}
|
||||||
|
AND gr.gen_type = 'video'
|
||||||
|
),
|
||||||
|
resolved AS (
|
||||||
|
SELECT
|
||||||
|
tr.id AS record_id,
|
||||||
|
ve.id AS selected_engine_id,
|
||||||
|
ve.name AS engine_name,
|
||||||
|
ve.provider,
|
||||||
|
ve.api_base,
|
||||||
|
ve.api_key,
|
||||||
|
ve.model_name,
|
||||||
|
ve.generate_url,
|
||||||
|
ve.query_url,
|
||||||
|
ve.supported_ratios,
|
||||||
|
ve.supported_resolutions,
|
||||||
|
ve.supported_durations,
|
||||||
|
ve.max_duration,
|
||||||
|
ve.max_image_count,
|
||||||
|
ve.max_video_count,
|
||||||
|
ve.max_audio_count,
|
||||||
|
ve.supports_universal_reference,
|
||||||
|
ve.supports_first_last_frame,
|
||||||
|
ve.multi_generation_enabled,
|
||||||
|
ve.max_generation_count,
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN tr.duration IS NOT NULL
|
||||||
|
AND tr.duration > 0
|
||||||
|
AND (
|
||||||
|
jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
|
||||||
|
WHERE d.value ~ '^[0-9]+$'
|
||||||
|
AND d.value::int = tr.duration
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
ve.max_duration IS NULL
|
||||||
|
OR ve.max_duration <= 0
|
||||||
|
OR tr.duration <= ve.max_duration
|
||||||
|
)
|
||||||
|
THEN tr.duration
|
||||||
|
END,
|
||||||
|
(
|
||||||
|
SELECT d.value::int
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) WITH ORDINALITY d(value, ord)
|
||||||
|
WHERE d.value ~ '^[0-9]+$'
|
||||||
|
AND d.value::int > 0
|
||||||
|
AND (
|
||||||
|
ve.max_duration IS NULL
|
||||||
|
OR ve.max_duration <= 0
|
||||||
|
OR d.value::int <= ve.max_duration
|
||||||
|
)
|
||||||
|
ORDER BY d.ord
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
CASE
|
||||||
|
WHEN ve.max_duration IS NOT NULL AND ve.max_duration > 0 THEN LEAST(4, ve.max_duration)
|
||||||
|
ELSE 4
|
||||||
|
END
|
||||||
|
) AS final_duration,
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN tr.aspect_ratio IS NOT NULL
|
||||||
|
AND tr.aspect_ratio <> ''
|
||||||
|
AND (
|
||||||
|
jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
|
||||||
|
WHERE r.value = tr.aspect_ratio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN tr.aspect_ratio
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
|
||||||
|
WHERE r.value = '16:9'
|
||||||
|
)
|
||||||
|
THEN '16:9'
|
||||||
|
END,
|
||||||
|
(
|
||||||
|
SELECT r.value
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) WITH ORDINALITY r(value, ord)
|
||||||
|
WHERE r.value <> ''
|
||||||
|
ORDER BY r.ord
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
'16:9'
|
||||||
|
) AS final_aspect_ratio,
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN tr.resolution IS NOT NULL
|
||||||
|
AND tr.resolution <> ''
|
||||||
|
AND (
|
||||||
|
jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
|
||||||
|
WHERE r.value = tr.resolution
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN tr.resolution
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
|
||||||
|
WHERE r.value = '480p'
|
||||||
|
)
|
||||||
|
THEN '480p'
|
||||||
|
END,
|
||||||
|
(
|
||||||
|
SELECT r.value
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) WITH ORDINALITY r(value, ord)
|
||||||
|
WHERE r.value <> ''
|
||||||
|
ORDER BY r.ord
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
'480p'
|
||||||
|
) AS final_resolution
|
||||||
|
FROM target_records tr
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT ve.*
|
||||||
|
FROM video_engines ve
|
||||||
|
WHERE ve.is_active IS TRUE
|
||||||
|
AND ve.deleted_at IS NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN (
|
||||||
|
(
|
||||||
|
tr.duration IS NULL
|
||||||
|
OR tr.duration <= 0
|
||||||
|
OR jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
|
||||||
|
WHERE d.value ~ '^[0-9]+$'
|
||||||
|
AND d.value::int = tr.duration
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
tr.aspect_ratio IS NULL
|
||||||
|
OR tr.aspect_ratio = ''
|
||||||
|
OR jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
|
||||||
|
WHERE r.value = tr.aspect_ratio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
tr.resolution IS NULL
|
||||||
|
OR tr.resolution = ''
|
||||||
|
OR jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
|
||||||
|
WHERE r.value = tr.resolution
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
ve.priority DESC,
|
||||||
|
ve.created_at ASC,
|
||||||
|
ve.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) ve ON TRUE
|
||||||
|
)
|
||||||
|
UPDATE generation_records gr
|
||||||
|
SET
|
||||||
|
engine_id = resolved.selected_engine_id,
|
||||||
|
duration = resolved.final_duration,
|
||||||
|
aspect_ratio = resolved.final_aspect_ratio,
|
||||||
|
resolution = resolved.final_resolution,
|
||||||
|
provider_generation_resolution = resolved.final_resolution,
|
||||||
|
video_upscale_enabled_snapshot = FALSE,
|
||||||
|
video_upscale_snapshot_json = NULL,
|
||||||
|
engine_snapshot_json = jsonb_build_object(
|
||||||
|
'engine_type', 'video',
|
||||||
|
'id', resolved.selected_engine_id,
|
||||||
|
'name', resolved.engine_name,
|
||||||
|
'provider', resolved.provider,
|
||||||
|
'api_base', resolved.api_base,
|
||||||
|
'api_key_masked', CASE WHEN COALESCE(resolved.api_key, '') <> '' THEN '****' ELSE '' END,
|
||||||
|
'model_name', resolved.model_name,
|
||||||
|
'generate_url', resolved.generate_url,
|
||||||
|
'query_url', resolved.query_url,
|
||||||
|
'supported_ratios', CASE
|
||||||
|
WHEN resolved.supported_ratios IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(resolved.supported_ratios::jsonb) = 'array'
|
||||||
|
THEN resolved.supported_ratios::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END,
|
||||||
|
'supported_resolutions', CASE
|
||||||
|
WHEN resolved.supported_resolutions IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(resolved.supported_resolutions::jsonb) = 'array'
|
||||||
|
THEN resolved.supported_resolutions::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END,
|
||||||
|
'supported_durations', CASE
|
||||||
|
WHEN resolved.supported_durations IS JSON THEN
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(resolved.supported_durations::jsonb) = 'array'
|
||||||
|
THEN resolved.supported_durations::jsonb
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END,
|
||||||
|
'max_duration', resolved.max_duration,
|
||||||
|
'max_image_count', COALESCE(resolved.max_image_count, 0),
|
||||||
|
'max_video_count', COALESCE(resolved.max_video_count, 0),
|
||||||
|
'max_audio_count', COALESCE(resolved.max_audio_count, 0),
|
||||||
|
'supports_universal_reference', COALESCE(resolved.supports_universal_reference, FALSE),
|
||||||
|
'supports_first_last_frame', COALESCE(resolved.supports_first_last_frame, FALSE),
|
||||||
|
'multi_generation_enabled', COALESCE(resolved.multi_generation_enabled, FALSE),
|
||||||
|
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(resolved.max_generation_count, 1))),
|
||||||
|
'selected_ratio', resolved.final_aspect_ratio,
|
||||||
|
'selected_resolution', resolved.final_resolution,
|
||||||
|
'selected_duration', resolved.final_duration
|
||||||
|
)::text,
|
||||||
|
include_media_references = COALESCE(gr.include_media_references, FALSE),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM resolved
|
||||||
|
WHERE gr.id = resolved.record_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_REPAIR_IMAGE_SQL = f"""
|
||||||
|
WITH target_records AS (
|
||||||
|
SELECT gr.*
|
||||||
|
FROM generation_records gr
|
||||||
|
JOIN projects p ON p.id = gr.project_id
|
||||||
|
WHERE {_TARGET_CONDITION}
|
||||||
|
AND gr.gen_type = 'image'
|
||||||
|
),
|
||||||
|
engine_selected AS (
|
||||||
|
SELECT
|
||||||
|
tr.*,
|
||||||
|
ie.id AS selected_engine_id,
|
||||||
|
ie.name AS engine_name,
|
||||||
|
ie.provider,
|
||||||
|
ie.api_base,
|
||||||
|
ie.api_key,
|
||||||
|
ie.model_name,
|
||||||
|
ie.generate_url,
|
||||||
|
ie.supported_models,
|
||||||
|
ie.supported_sizes,
|
||||||
|
ie.default_size,
|
||||||
|
ie.multi_generation_enabled,
|
||||||
|
ie.max_generation_count,
|
||||||
|
ie.multi_image_max_images,
|
||||||
|
ie.max_reference_image_count,
|
||||||
|
ie.output_format,
|
||||||
|
CASE
|
||||||
|
WHEN ie.supported_sizes IS JSON AND jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
|
||||||
|
THEN ie.supported_sizes::jsonb
|
||||||
|
ELSE '{{}}'::jsonb
|
||||||
|
END AS sizes_json
|
||||||
|
FROM target_records tr
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT ie.*
|
||||||
|
FROM image_engines ie
|
||||||
|
WHERE ie.is_active IS TRUE
|
||||||
|
AND ie.deleted_at IS NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN (
|
||||||
|
ie.supported_sizes IS NULL
|
||||||
|
OR ie.supported_sizes = ''
|
||||||
|
OR ie.supported_sizes IS NOT JSON
|
||||||
|
OR jsonb_typeof(ie.supported_sizes::jsonb) <> 'object'
|
||||||
|
OR ie.supported_sizes::jsonb = '{{}}'::jsonb
|
||||||
|
OR (
|
||||||
|
(
|
||||||
|
tr.image_size IS NULL
|
||||||
|
OR tr.image_size = ''
|
||||||
|
OR ie.supported_sizes::jsonb ? tr.image_size
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
tr.image_size IS NULL
|
||||||
|
OR tr.image_size = ''
|
||||||
|
OR tr.image_proportion IS NULL
|
||||||
|
OR tr.image_proportion = ''
|
||||||
|
OR (
|
||||||
|
jsonb_typeof(ie.supported_sizes::jsonb -> tr.image_size) = 'object'
|
||||||
|
AND (ie.supported_sizes::jsonb -> tr.image_size) ? tr.image_proportion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
ie.priority DESC,
|
||||||
|
ie.created_at ASC,
|
||||||
|
ie.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) ie ON TRUE
|
||||||
|
),
|
||||||
|
size_selected AS (
|
||||||
|
SELECT
|
||||||
|
es.*,
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN es.image_size IS NOT NULL
|
||||||
|
AND es.image_size <> ''
|
||||||
|
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.image_size)
|
||||||
|
THEN es.image_size
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN es.default_size IS NOT NULL
|
||||||
|
AND es.default_size <> ''
|
||||||
|
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.default_size)
|
||||||
|
THEN es.default_size
|
||||||
|
END,
|
||||||
|
CASE WHEN es.sizes_json ? '2K' THEN '2K' END,
|
||||||
|
(
|
||||||
|
SELECT key
|
||||||
|
FROM jsonb_object_keys(es.sizes_json) AS key
|
||||||
|
ORDER BY key
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
'2K'
|
||||||
|
) AS final_image_size
|
||||||
|
FROM engine_selected es
|
||||||
|
),
|
||||||
|
ratio_selected AS (
|
||||||
|
SELECT
|
||||||
|
ss.*,
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(ss.sizes_json -> ss.final_image_size) = 'object'
|
||||||
|
THEN ss.sizes_json -> ss.final_image_size
|
||||||
|
ELSE '{{}}'::jsonb
|
||||||
|
END AS ratio_json
|
||||||
|
FROM size_selected ss
|
||||||
|
),
|
||||||
|
final_resolved AS (
|
||||||
|
SELECT
|
||||||
|
rs.*,
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN rs.image_proportion IS NOT NULL
|
||||||
|
AND rs.image_proportion <> ''
|
||||||
|
AND (rs.ratio_json = '{{}}'::jsonb OR rs.ratio_json ? rs.image_proportion)
|
||||||
|
THEN rs.image_proportion
|
||||||
|
END,
|
||||||
|
CASE WHEN rs.ratio_json ? '1:1' THEN '1:1' END,
|
||||||
|
(
|
||||||
|
SELECT key
|
||||||
|
FROM jsonb_object_keys(rs.ratio_json) AS key
|
||||||
|
ORDER BY key
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
'1:1'
|
||||||
|
) AS final_image_proportion
|
||||||
|
FROM ratio_selected rs
|
||||||
|
),
|
||||||
|
px_resolved AS (
|
||||||
|
SELECT
|
||||||
|
fr.*,
|
||||||
|
regexp_replace(
|
||||||
|
lower(
|
||||||
|
replace(
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(fr.ratio_json ->> fr.final_image_proportion, ''),
|
||||||
|
NULLIF(fr.image_px, ''),
|
||||||
|
'2048x2048'
|
||||||
|
),
|
||||||
|
'×',
|
||||||
|
'x'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'x+',
|
||||||
|
'x',
|
||||||
|
'g'
|
||||||
|
) AS final_image_px
|
||||||
|
FROM final_resolved fr
|
||||||
|
)
|
||||||
|
UPDATE generation_records gr
|
||||||
|
SET
|
||||||
|
engine_id = px_resolved.selected_engine_id,
|
||||||
|
image_size = px_resolved.final_image_size,
|
||||||
|
image_proportion = px_resolved.final_image_proportion,
|
||||||
|
image_px = LEFT(px_resolved.final_image_px, 10),
|
||||||
|
provider_generation_resolution = NULL,
|
||||||
|
video_upscale_enabled_snapshot = FALSE,
|
||||||
|
video_upscale_snapshot_json = NULL,
|
||||||
|
engine_snapshot_json = jsonb_build_object(
|
||||||
|
'engine_type', 'image',
|
||||||
|
'id', px_resolved.selected_engine_id,
|
||||||
|
'name', px_resolved.engine_name,
|
||||||
|
'provider', px_resolved.provider,
|
||||||
|
'api_base', px_resolved.api_base,
|
||||||
|
'api_key_masked', CASE WHEN COALESCE(px_resolved.api_key, '') <> '' THEN '****' ELSE '' END,
|
||||||
|
'model_name', px_resolved.model_name,
|
||||||
|
'generate_url', px_resolved.generate_url,
|
||||||
|
'supported_models', {_IMAGE_SUPPORTED_MODELS_JSON},
|
||||||
|
'default_size', px_resolved.default_size,
|
||||||
|
'multi_generation_enabled', COALESCE(px_resolved.multi_generation_enabled, FALSE),
|
||||||
|
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(px_resolved.max_generation_count, 1))),
|
||||||
|
'multi_image_max_images', COALESCE(px_resolved.multi_image_max_images, 15),
|
||||||
|
'max_reference_image_count', COALESCE(px_resolved.max_reference_image_count, 0),
|
||||||
|
'output_format', lower(trim(COALESCE(px_resolved.output_format, ''))),
|
||||||
|
'selected_size', px_resolved.final_image_size,
|
||||||
|
'selected_proportion', px_resolved.final_image_proportion,
|
||||||
|
'selected_px', LEFT(px_resolved.final_image_px, 10)
|
||||||
|
)::text,
|
||||||
|
include_media_references = COALESCE(gr.include_media_references, FALSE),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM px_resolved
|
||||||
|
WHERE gr.id = px_resolved.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_DOWNGRADE_SQL = f"""
|
||||||
|
UPDATE generation_records gr
|
||||||
|
SET
|
||||||
|
engine_id = b.old_engine_id,
|
||||||
|
engine_snapshot_json = b.old_engine_snapshot_json,
|
||||||
|
duration = b.old_duration,
|
||||||
|
aspect_ratio = b.old_aspect_ratio,
|
||||||
|
resolution = b.old_resolution,
|
||||||
|
provider_generation_resolution = b.old_provider_generation_resolution,
|
||||||
|
video_upscale_enabled_snapshot = COALESCE(b.old_video_upscale_enabled_snapshot, FALSE),
|
||||||
|
video_upscale_snapshot_json = b.old_video_upscale_snapshot_json,
|
||||||
|
image_size = b.old_image_size,
|
||||||
|
image_proportion = b.old_image_proportion,
|
||||||
|
image_px = b.old_image_px,
|
||||||
|
include_media_references = COALESCE(b.old_include_media_references, FALSE),
|
||||||
|
updated_at = b.old_updated_at
|
||||||
|
FROM {BACKUP_TABLE} b
|
||||||
|
WHERE b.repair_key = :repair_key
|
||||||
|
AND b.record_id = gr.id
|
||||||
|
AND gr.status = 'prompt_optimized'
|
||||||
|
AND gr.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_DROP_BACKUP_TABLE_SQL = f"DROP TABLE IF EXISTS {BACKUP_TABLE}"
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar_int(sql: str, **params: object) -> int:
|
||||||
|
bind = op.get_bind()
|
||||||
|
value = bind.execute(sa.text(sql), params).scalar()
|
||||||
|
return int(value or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_required_engines() -> None:
|
||||||
|
video_targets = _scalar_int(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM generation_records gr
|
||||||
|
JOIN projects p ON p.id = gr.project_id
|
||||||
|
WHERE {_TARGET_CONDITION}
|
||||||
|
AND gr.gen_type = 'video'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
image_targets = _scalar_int(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM generation_records gr
|
||||||
|
JOIN projects p ON p.id = gr.project_id
|
||||||
|
WHERE {_TARGET_CONDITION}
|
||||||
|
AND gr.gen_type = 'image'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
video_engines = _scalar_int(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM video_engines
|
||||||
|
WHERE is_active IS TRUE
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
image_engines = _scalar_int(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM image_engines
|
||||||
|
WHERE is_active IS TRUE
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if video_targets > 0 and video_engines <= 0:
|
||||||
|
raise RuntimeError("存在待修复的视频生成记录,但没有可用的视频引擎")
|
||||||
|
if image_targets > 0 and image_engines <= 0:
|
||||||
|
raise RuntimeError("存在待修复的图片生成记录,但没有可用的图片引擎")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_postgresql() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect_name = getattr(bind.dialect, "name", "")
|
||||||
|
if dialect_name != "postgresql":
|
||||||
|
raise RuntimeError("本迁移只支持 PostgreSQL,当前数据库类型不支持此数据修复")
|
||||||
|
|
||||||
|
|
||||||
|
def _execute(sql: str, **params: object) -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
bind.execute(sa.text(sql), params)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_table_exists() -> bool:
|
||||||
|
return (
|
||||||
|
_scalar_int(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = :table_name
|
||||||
|
""",
|
||||||
|
table_name=BACKUP_TABLE,
|
||||||
|
)
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Repair historical prompt_optimized GenerationRecord frozen config.
|
||||||
|
|
||||||
|
This migration only fills missing frozen generation configuration for old
|
||||||
|
records. It does not change status, charge credits, create tasks, enqueue
|
||||||
|
Celery jobs, or call application services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ensure_postgresql()
|
||||||
|
_execute(_CREATE_BACKUP_TABLE_SQL)
|
||||||
|
_ensure_required_engines()
|
||||||
|
|
||||||
|
# Keep the original values for a guarded downgrade. The ON CONFLICT clause
|
||||||
|
# makes this migration safe to re-run inside a partially repaired database.
|
||||||
|
_execute(_INSERT_BACKUP_SQL, repair_key=REPAIR_KEY)
|
||||||
|
|
||||||
|
# Repair by generation type. The update SQL only targets records that are
|
||||||
|
# still prompt_optimized and still incomplete, so already repaired records
|
||||||
|
# are skipped.
|
||||||
|
_execute(_REPAIR_VIDEO_SQL)
|
||||||
|
_execute(_REPAIR_IMAGE_SQL)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Restore backed-up values for records that are still not generated.
|
||||||
|
|
||||||
|
Records that moved past prompt_optimized are intentionally not restored;
|
||||||
|
reverting those after users have generated media would corrupt production
|
||||||
|
state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ensure_postgresql()
|
||||||
|
if not _backup_table_exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
_execute(_DOWNGRADE_SQL, repair_key=REPAIR_KEY)
|
||||||
|
_execute(_DROP_BACKUP_TABLE_SQL)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""add celery runtime fencing fields
|
||||||
|
|
||||||
|
Revision ID: 7cf645f7c418
|
||||||
|
Revises: d8ebe79ab575
|
||||||
|
Create Date: 2026-07-22 13:45:57.949417
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine.reflection import Inspector
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7cf645f7c418"
|
||||||
|
down_revision: str | None = "d8ebe79ab575"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_SHOT_SEGMENT_TABLE = "shot_replicate_segments"
|
||||||
|
_SHOT_TASK_SET_TABLE = "shot_replicate_task_sets"
|
||||||
|
_ANALYSIS_ATTEMPT_COLUMN = "analysis_attempt_no"
|
||||||
|
|
||||||
|
|
||||||
|
# SQLAlchemy Inspector caches reflected metadata. Always create a fresh
|
||||||
|
# inspector after DDL so partially applied migrations are detected correctly.
|
||||||
|
def _inspector() -> Inspector:
|
||||||
|
return sa.inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _require_table(table_name: str) -> None:
|
||||||
|
if not _inspector().has_table(table_name):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Required table {table_name!r} does not exist; "
|
||||||
|
"refusing to mark migration 7cf645f7c418 as applied incompletely."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(table_name: str) -> set[str]:
|
||||||
|
return {
|
||||||
|
str(column["name"])
|
||||||
|
for column in _inspector().get_columns(table_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column_if_missing(table_name: str, column: sa.Column[object]) -> bool:
|
||||||
|
"""Add one column only when its name is absent.
|
||||||
|
|
||||||
|
Returns True when DDL was executed and False when the column already exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if column.name in _column_names(table_name):
|
||||||
|
return False
|
||||||
|
op.add_column(table_name, column)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_column_if_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Drop one column only when both the table and column still exist."""
|
||||||
|
|
||||||
|
inspector = _inspector()
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
if column_name not in {
|
||||||
|
str(column["name"])
|
||||||
|
for column in inspector.get_columns(table_name)
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
op.drop_column(table_name, column_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _index_definitions(table_name: str) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
str(index["name"]): index
|
||||||
|
for index in _inspector().get_indexes(table_name)
|
||||||
|
if index.get("name")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_index(
|
||||||
|
index_name: str,
|
||||||
|
table_name: str,
|
||||||
|
columns: Sequence[str],
|
||||||
|
*,
|
||||||
|
unique: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Create an index if absent and reject a conflicting same-name index."""
|
||||||
|
|
||||||
|
required_columns = tuple(columns)
|
||||||
|
existing_columns = _column_names(table_name)
|
||||||
|
missing_columns = [name for name in required_columns if name not in existing_columns]
|
||||||
|
if missing_columns:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot create index {index_name!r}: table {table_name!r} "
|
||||||
|
f"is missing columns {missing_columns!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = _index_definitions(table_name).get(index_name)
|
||||||
|
if existing is not None:
|
||||||
|
reflected_columns = tuple(
|
||||||
|
str(name)
|
||||||
|
for name in (existing.get("column_names") or [])
|
||||||
|
)
|
||||||
|
reflected_unique = bool(existing.get("unique", False))
|
||||||
|
if reflected_columns != required_columns or reflected_unique != unique:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {index_name!r} already exists with an unexpected definition: "
|
||||||
|
f"columns={reflected_columns!r}, unique={reflected_unique!r}; "
|
||||||
|
f"expected columns={required_columns!r}, unique={unique!r}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
index_name,
|
||||||
|
table_name,
|
||||||
|
list(required_columns),
|
||||||
|
unique=unique,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_index_if_exists(index_name: str, table_name: str) -> bool:
|
||||||
|
"""Drop one index only when the table and named index still exist."""
|
||||||
|
|
||||||
|
inspector = _inspector()
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
existing_names = {
|
||||||
|
str(index["name"])
|
||||||
|
for index in inspector.get_indexes(table_name)
|
||||||
|
if index.get("name")
|
||||||
|
}
|
||||||
|
if index_name not in existing_names:
|
||||||
|
return False
|
||||||
|
op.drop_index(index_name, table_name=table_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_analysis_attempt_column(table_name: str) -> None:
|
||||||
|
"""Create/backfill the non-null attempt counter safely for existing rows."""
|
||||||
|
|
||||||
|
_add_column_if_missing(
|
||||||
|
table_name,
|
||||||
|
sa.Column(
|
||||||
|
_ANALYSIS_ATTEMPT_COLUMN,
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also repairs a partially applied/manual migration where the column exists
|
||||||
|
# but contains NULL values or still carries the temporary database default.
|
||||||
|
quoted_table = op.get_bind().dialect.identifier_preparer.quote(table_name)
|
||||||
|
quoted_column = op.get_bind().dialect.identifier_preparer.quote(
|
||||||
|
_ANALYSIS_ATTEMPT_COLUMN
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"UPDATE {quoted_table} "
|
||||||
|
f"SET {quoted_column} = 1 "
|
||||||
|
f"WHERE {quoted_column} IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
table_name,
|
||||||
|
_ANALYSIS_ATTEMPT_COLUMN,
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add only the Celery fencing fields required by shot replication."""
|
||||||
|
|
||||||
|
_require_table(_SHOT_SEGMENT_TABLE)
|
||||||
|
_require_table(_SHOT_TASK_SET_TABLE)
|
||||||
|
|
||||||
|
# Segment split fencing.
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
sa.Column("split_claim_token", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Segment analysis fencing.
|
||||||
|
_ensure_analysis_attempt_column(_SHOT_SEGMENT_TABLE)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ensure_index(
|
||||||
|
"idx_shot_segments_analysis_lease",
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
("analysis_status", "analysis_lease_until"),
|
||||||
|
)
|
||||||
|
_ensure_index(
|
||||||
|
"idx_shot_segments_split_lease",
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
("split_status", "split_lease_until"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Task-set analysis fencing.
|
||||||
|
_ensure_analysis_attempt_column(_SHOT_TASK_SET_TABLE)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_TASK_SET_TABLE,
|
||||||
|
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_TASK_SET_TABLE,
|
||||||
|
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
_SHOT_TASK_SET_TABLE,
|
||||||
|
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ensure_index(
|
||||||
|
"idx_shot_task_sets_analysis_lease",
|
||||||
|
_SHOT_TASK_SET_TABLE,
|
||||||
|
("analysis_status", "analysis_lease_until"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove only fields and indexes introduced by this revision.
|
||||||
|
|
||||||
|
Every operation is guarded so a partially reverted database does not fail
|
||||||
|
merely because an index or column is already absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_drop_index_if_exists(
|
||||||
|
"idx_shot_task_sets_analysis_lease",
|
||||||
|
_SHOT_TASK_SET_TABLE,
|
||||||
|
)
|
||||||
|
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_lease_until")
|
||||||
|
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_started_at")
|
||||||
|
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_claim_token")
|
||||||
|
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
|
||||||
|
|
||||||
|
_drop_index_if_exists(
|
||||||
|
"idx_shot_segments_split_lease",
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
)
|
||||||
|
_drop_index_if_exists(
|
||||||
|
"idx_shot_segments_analysis_lease",
|
||||||
|
_SHOT_SEGMENT_TABLE,
|
||||||
|
)
|
||||||
|
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_lease_until")
|
||||||
|
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_started_at")
|
||||||
|
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_claim_token")
|
||||||
|
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
|
||||||
|
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "split_claim_token")
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""add client-selectable multi generation and image batch claim
|
||||||
|
|
||||||
|
Revision ID: abae3e1c70f7
|
||||||
|
Revises: 2026070902
|
||||||
|
Create Date: 2026-07-15 10:49:31.803342
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "abae3e1c70f7"
|
||||||
|
down_revision: Union[str, None] = "2026070902"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
FK_CHAT_TASK_PARENT = "fk_chat_generation_tasks_parent_task_id"
|
||||||
|
CK_CHAT_TASK_GENERATION_COUNT = "ck_chat_generation_tasks_generation_count"
|
||||||
|
CK_CHAT_TASK_GENERATION_INDEX = "ck_chat_generation_tasks_generation_index"
|
||||||
|
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT = "ck_image_engines_max_generation_count"
|
||||||
|
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX = "ck_image_engines_multi_image_max_images"
|
||||||
|
CK_IMAGE_ENGINE_MAX_REFERENCE = "ck_image_engines_max_reference_image_count"
|
||||||
|
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT = "ck_video_engines_max_generation_count"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ChatGenerationTask:任务级实际生成数量、主子关联和图片批次执行租约。
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("parent_task_id", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("generation_index", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("provider_create_claim_token", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("provider_create_lease_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"chat_generation_tasks",
|
||||||
|
sa.Column("provider_create_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_CHAT_TASK_GENERATION_COUNT,
|
||||||
|
"chat_generation_tasks",
|
||||||
|
"generation_count BETWEEN 1 AND 5",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_CHAT_TASK_GENERATION_INDEX,
|
||||||
|
"chat_generation_tasks",
|
||||||
|
"generation_index IS NULL OR generation_index > 0",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
FK_CHAT_TASK_PARENT,
|
||||||
|
"chat_generation_tasks",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["parent_task_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_chat_generation_tasks_parent",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["parent_task_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_chat_generation_tasks_user_mode_created",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["user_id", "generation_mode", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_chat_generation_tasks_provider_create_claim_token",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["provider_create_claim_token"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_chat_generation_tasks_provider_create_lease_until",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["provider_create_lease_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_chat_generation_tasks_parent_index",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["parent_task_id", "generation_index"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_chat_generation_tasks_user_chat_idempotency",
|
||||||
|
"chat_generation_tasks",
|
||||||
|
["user_id", "idempotency_key"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"deleted_at IS NULL "
|
||||||
|
"AND idempotency_key IS NOT NULL "
|
||||||
|
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ImageEngine:管理后台只配置是否允许客户端多份生成和数量上限。
|
||||||
|
op.add_column(
|
||||||
|
"image_engines",
|
||||||
|
sa.Column(
|
||||||
|
"multi_generation_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"image_engines",
|
||||||
|
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"image_engines",
|
||||||
|
sa.Column("multi_image_max_images", sa.Integer(), server_default=sa.text("15"), nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"image_engines",
|
||||||
|
sa.Column("max_reference_image_count", sa.Integer(), server_default=sa.text("14"), nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"image_engines",
|
||||||
|
sa.Column("output_format", sa.String(length=16), server_default=sa.text("''"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT,
|
||||||
|
"image_engines",
|
||||||
|
"max_generation_count BETWEEN 1 AND 5",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX,
|
||||||
|
"image_engines",
|
||||||
|
"multi_image_max_images BETWEEN 1 AND 15",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_IMAGE_ENGINE_MAX_REFERENCE,
|
||||||
|
"image_engines",
|
||||||
|
"max_reference_image_count BETWEEN 0 AND 14",
|
||||||
|
)
|
||||||
|
|
||||||
|
# VideoEngine:管理后台只配置是否允许客户端多份生成和数量上限。
|
||||||
|
op.add_column(
|
||||||
|
"video_engines",
|
||||||
|
sa.Column(
|
||||||
|
"multi_generation_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"video_engines",
|
||||||
|
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT,
|
||||||
|
"video_engines",
|
||||||
|
"max_generation_count BETWEEN 1 AND 5",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(CK_VIDEO_ENGINE_MAX_GENERATION_COUNT, "video_engines", type_="check")
|
||||||
|
op.drop_column("video_engines", "max_generation_count")
|
||||||
|
op.drop_column("video_engines", "multi_generation_enabled")
|
||||||
|
|
||||||
|
op.drop_constraint(CK_IMAGE_ENGINE_MAX_REFERENCE, "image_engines", type_="check")
|
||||||
|
op.drop_constraint(CK_IMAGE_ENGINE_MULTI_IMAGE_MAX, "image_engines", type_="check")
|
||||||
|
op.drop_constraint(CK_IMAGE_ENGINE_MAX_GENERATION_COUNT, "image_engines", type_="check")
|
||||||
|
op.drop_column("image_engines", "output_format")
|
||||||
|
op.drop_column("image_engines", "max_reference_image_count")
|
||||||
|
op.drop_column("image_engines", "multi_image_max_images")
|
||||||
|
op.drop_column("image_engines", "max_generation_count")
|
||||||
|
op.drop_column("image_engines", "multi_generation_enabled")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"uq_chat_generation_tasks_user_chat_idempotency",
|
||||||
|
table_name="chat_generation_tasks",
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"deleted_at IS NULL "
|
||||||
|
"AND idempotency_key IS NOT NULL "
|
||||||
|
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_chat_generation_tasks_parent_index",
|
||||||
|
table_name="chat_generation_tasks",
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_chat_generation_tasks_provider_create_lease_until",
|
||||||
|
table_name="chat_generation_tasks",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_chat_generation_tasks_provider_create_claim_token",
|
||||||
|
table_name="chat_generation_tasks",
|
||||||
|
)
|
||||||
|
op.drop_index("idx_chat_generation_tasks_user_mode_created", table_name="chat_generation_tasks")
|
||||||
|
op.drop_index("idx_chat_generation_tasks_parent", table_name="chat_generation_tasks")
|
||||||
|
op.drop_constraint(FK_CHAT_TASK_PARENT, "chat_generation_tasks", type_="foreignkey")
|
||||||
|
op.drop_constraint(CK_CHAT_TASK_GENERATION_INDEX, "chat_generation_tasks", type_="check")
|
||||||
|
op.drop_constraint(CK_CHAT_TASK_GENERATION_COUNT, "chat_generation_tasks", type_="check")
|
||||||
|
op.drop_column("chat_generation_tasks", "provider_create_started_at")
|
||||||
|
op.drop_column("chat_generation_tasks", "provider_create_lease_until")
|
||||||
|
op.drop_column("chat_generation_tasks", "provider_create_claim_token")
|
||||||
|
op.drop_column("chat_generation_tasks", "generation_index")
|
||||||
|
op.drop_column("chat_generation_tasks", "generation_count")
|
||||||
|
op.drop_column("chat_generation_tasks", "parent_task_id")
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
"""add module flow version and generation reference option
|
||||||
|
|
||||||
|
Revision ID: d8ebe79ab575
|
||||||
|
Revises: e6eac828ff61
|
||||||
|
Create Date: 2026-07-21 09:28:04.318458
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "d8ebe79ab575"
|
||||||
|
down_revision: Union[str, None] = "e6eac828ff61"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(table_name: str) -> set[str]:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
return {str(column["name"]) for column in inspector.get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
generation_record_columns = _column_names("generation_records")
|
||||||
|
if "include_media_references" not in generation_record_columns:
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column(
|
||||||
|
"include_media_references",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE generation_records "
|
||||||
|
"SET include_media_references = false "
|
||||||
|
"WHERE include_media_references IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"generation_records",
|
||||||
|
"include_media_references",
|
||||||
|
existing_type=sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
project_columns = _column_names("module_generation_projects")
|
||||||
|
if "flow_version" not in project_columns:
|
||||||
|
op.add_column(
|
||||||
|
"module_generation_projects",
|
||||||
|
sa.Column(
|
||||||
|
"flow_version",
|
||||||
|
sa.String(length=16),
|
||||||
|
server_default=sa.text("'v1'"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE module_generation_projects "
|
||||||
|
"SET flow_version = 'v1' "
|
||||||
|
"WHERE flow_version IS NULL OR btrim(flow_version) = ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"module_generation_projects",
|
||||||
|
"flow_version",
|
||||||
|
existing_type=sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'v1'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
project_columns = _column_names("module_generation_projects")
|
||||||
|
if "flow_version" in project_columns:
|
||||||
|
op.drop_column("module_generation_projects", "flow_version")
|
||||||
|
|
||||||
|
generation_record_columns = _column_names("generation_records")
|
||||||
|
if "include_media_references" in generation_record_columns:
|
||||||
|
op.drop_column("generation_records", "include_media_references")
|
||||||
+1276
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||||
|
from app.api.admin.video_upscale import router as video_upscale_router
|
||||||
from app.api.admin.resource_capacity import router as resource_capacity_router
|
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||||
from app.api.admin.team import router as team_router
|
from app.api.admin.team import router as team_router
|
||||||
from app.api.admin.home_material import router as home_material_router
|
from app.api.admin.home_material import router as home_material_router
|
||||||
@@ -8,10 +9,11 @@ from app.api.admin.private_portrait import router as private_portrait_router
|
|||||||
from app.api.admin.recharge_package import router as recharge_package_router
|
from app.api.admin.recharge_package import router as recharge_package_router
|
||||||
from app.api.admin.menu_config import router as menu_config_router
|
from app.api.admin.menu_config import router as menu_config_router
|
||||||
from app.api.admin.upload import router as admin_upload_router
|
from app.api.admin.upload import router as admin_upload_router
|
||||||
from app.api.admin.model_pricing import router as model_pricing_router
|
from app.api.admin.contact import router as admin_contact_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
router.include_router(video_prompt_schema_config_router)
|
||||||
|
router.include_router(video_upscale_router)
|
||||||
router.include_router(resource_capacity_router)
|
router.include_router(resource_capacity_router)
|
||||||
router.include_router(team_router)
|
router.include_router(team_router)
|
||||||
router.include_router(home_material_router)
|
router.include_router(home_material_router)
|
||||||
@@ -19,4 +21,4 @@ router.include_router(private_portrait_router)
|
|||||||
router.include_router(recharge_package_router)
|
router.include_router(recharge_package_router)
|
||||||
router.include_router(menu_config_router)
|
router.include_router(menu_config_router)
|
||||||
router.include_router(admin_upload_router)
|
router.include_router(admin_upload_router)
|
||||||
router.include_router(model_pricing_router)
|
router.include_router(admin_contact_router)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.contact_request import ContactRequest
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.contact import ContactRequestListOut, ContactRequestOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/contact-requests", tags=["admin-contact-requests"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ContactRequestListOut)
|
||||||
|
async def list_contact_requests(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=200),
|
||||||
|
is_handled: bool | None = Query(None),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(ContactRequest)
|
||||||
|
count_query = select(func.count(ContactRequest.id))
|
||||||
|
if is_handled is not None:
|
||||||
|
query = query.where(ContactRequest.is_handled == is_handled)
|
||||||
|
count_query = count_query.where(ContactRequest.is_handled == is_handled)
|
||||||
|
query = query.order_by(ContactRequest.created_at.desc())
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
result = await db.execute(query.offset(offset).limit(page_size))
|
||||||
|
items = result.scalars().all()
|
||||||
|
total = (await db.execute(count_query)).scalar_one()
|
||||||
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}", response_model=ContactRequestOut)
|
||||||
|
async def get_contact_request(
|
||||||
|
request_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{request_id}/handle")
|
||||||
|
async def mark_contact_handled(
|
||||||
|
request_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
item.is_handled = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(item)
|
||||||
|
return {"message": "已标记为处理"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{request_id}")
|
||||||
|
async def delete_contact_request(
|
||||||
|
request_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
await db.delete(item)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "删除成功"}
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.model_pricing import (
|
|
||||||
ModelPricingPreviewOut,
|
|
||||||
ModelPricingPreviewRequest,
|
|
||||||
ModelPricingRuleCreate,
|
|
||||||
ModelPricingRuleListOut,
|
|
||||||
ModelPricingRuleOut,
|
|
||||||
ModelPricingRuleUpdate,
|
|
||||||
)
|
|
||||||
from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing
|
|
||||||
from app.services.model_pricing.rule_service import (
|
|
||||||
PricingRuleError,
|
|
||||||
create_rule,
|
|
||||||
disable_rule,
|
|
||||||
get_rule_snapshot,
|
|
||||||
list_rules,
|
|
||||||
publish_rule,
|
|
||||||
update_draft_rule,
|
|
||||||
)
|
|
||||||
from app.services.operation_log import log_operation
|
|
||||||
from app.services.operation_log_service import log_model_pricing_event
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/model-pricing", tags=["admin-model-pricing"])
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error(exc: Exception) -> HTTPException:
|
|
||||||
return HTTPException(status_code=400, detail=str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules", response_model=ModelPricingRuleListOut)
|
|
||||||
async def admin_list_model_pricing_rules(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(50, ge=1, le=500),
|
|
||||||
provider: str | None = Query(None),
|
|
||||||
model_name: str | None = Query(None),
|
|
||||||
model_category: str | None = Query(None),
|
|
||||||
publish_status: str | None = Query(None),
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
return await list_rules(
|
|
||||||
db,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
provider=provider,
|
|
||||||
model_name=model_name,
|
|
||||||
model_category=model_category,
|
|
||||||
publish_status=publish_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules/{rule_id}", response_model=ModelPricingRuleOut)
|
|
||||||
async def admin_get_model_pricing_rule(
|
|
||||||
rule_id: str,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
snapshot = await get_rule_snapshot(db, rule_id)
|
|
||||||
return {**snapshot, "referenced_count": 0}
|
|
||||||
except PricingRuleError as exc:
|
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules", response_model=ModelPricingRuleOut)
|
|
||||||
async def admin_create_model_pricing_rule(
|
|
||||||
req: ModelPricingRuleCreate,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
admin_id = str(admin.id)
|
|
||||||
admin_username = str(admin.username or "")
|
|
||||||
try:
|
|
||||||
snapshot = await create_rule(db, payload=req.model_dump(), operator_id=admin_id)
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin_id,
|
|
||||||
admin_username,
|
|
||||||
f"创建模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
|
|
||||||
"POST",
|
|
||||||
"/admin/model-pricing/rules",
|
|
||||||
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
log_model_pricing_event(
|
|
||||||
event_type="pricing_rule_validate",
|
|
||||||
user_id=admin_id,
|
|
||||||
pricing_rule_id=snapshot["id"],
|
|
||||||
pricing_version=snapshot["version_code"],
|
|
||||||
provider=snapshot["provider"],
|
|
||||||
model_name=snapshot["model_name"],
|
|
||||||
billing_mode=snapshot["billing_mode"],
|
|
||||||
message="模型计价草稿创建成功",
|
|
||||||
)
|
|
||||||
return {**snapshot, "referenced_count": 0}
|
|
||||||
except IntegrityError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
|
|
||||||
except PricingRuleError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise _http_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rules/{rule_id}", response_model=ModelPricingRuleOut)
|
|
||||||
async def admin_update_model_pricing_rule(
|
|
||||||
rule_id: str,
|
|
||||||
req: ModelPricingRuleUpdate,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
admin_id = str(admin.id)
|
|
||||||
admin_username = str(admin.username or "")
|
|
||||||
try:
|
|
||||||
snapshot = await update_draft_rule(
|
|
||||||
db,
|
|
||||||
rule_id=rule_id,
|
|
||||||
payload=req.model_dump(exclude_unset=True),
|
|
||||||
operator_id=admin_id,
|
|
||||||
)
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin_id,
|
|
||||||
admin_username,
|
|
||||||
f"更新模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}",
|
|
||||||
"PUT",
|
|
||||||
f"/admin/model-pricing/rules/{rule_id}",
|
|
||||||
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {**snapshot, "referenced_count": 0}
|
|
||||||
except IntegrityError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
|
|
||||||
except PricingRuleError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise _http_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_id}/publish", response_model=ModelPricingRuleOut)
|
|
||||||
async def admin_publish_model_pricing_rule(
|
|
||||||
rule_id: str,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
admin_id = str(admin.id)
|
|
||||||
admin_username = str(admin.username or "")
|
|
||||||
try:
|
|
||||||
snapshot = await publish_rule(db, rule_id=rule_id, operator_id=admin_id)
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin_id,
|
|
||||||
admin_username,
|
|
||||||
f"发布模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
|
|
||||||
"POST",
|
|
||||||
f"/admin/model-pricing/rules/{rule_id}/publish",
|
|
||||||
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
log_model_pricing_event(
|
|
||||||
event_type="pricing_rule_publish",
|
|
||||||
user_id=admin_id,
|
|
||||||
pricing_rule_id=snapshot["id"],
|
|
||||||
pricing_version=snapshot["version_code"],
|
|
||||||
provider=snapshot["provider"],
|
|
||||||
model_name=snapshot["model_name"],
|
|
||||||
billing_mode=snapshot["billing_mode"],
|
|
||||||
)
|
|
||||||
return {**snapshot, "referenced_count": 0}
|
|
||||||
except IntegrityError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
|
|
||||||
except PricingRuleError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise _http_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_id}/disable", response_model=ModelPricingRuleOut)
|
|
||||||
async def admin_disable_model_pricing_rule(
|
|
||||||
rule_id: str,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
admin_id = str(admin.id)
|
|
||||||
admin_username = str(admin.username or "")
|
|
||||||
try:
|
|
||||||
snapshot = await disable_rule(db, rule_id=rule_id, operator_id=admin_id)
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin_id,
|
|
||||||
admin_username,
|
|
||||||
f"停用模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}",
|
|
||||||
"POST",
|
|
||||||
f"/admin/model-pricing/rules/{rule_id}/disable",
|
|
||||||
detail=json.dumps(snapshot, ensure_ascii=False, default=str),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {**snapshot, "referenced_count": 0}
|
|
||||||
except IntegrityError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc
|
|
||||||
except PricingRuleError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise _http_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preview", response_model=ModelPricingPreviewOut)
|
|
||||||
async def admin_preview_model_pricing(
|
|
||||||
req: ModelPricingPreviewRequest,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
result = calculate_pricing(
|
|
||||||
billing_mode=req.billing_mode,
|
|
||||||
calculator_version=req.calculator_version,
|
|
||||||
rule_json=req.rule_json,
|
|
||||||
usage=req.usage,
|
|
||||||
currency=req.currency,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"amount": str(result.amount),
|
|
||||||
"currency": result.currency,
|
|
||||||
"is_estimated": result.is_estimated,
|
|
||||||
"selected_rate": str(result.selected_rate) if result.selected_rate is not None else None,
|
|
||||||
"usage_source": result.usage_source,
|
|
||||||
"breakdown": result.breakdown,
|
|
||||||
}
|
|
||||||
except PricingCalculationError as exc:
|
|
||||||
raise _http_error(exc) from exc
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.video_upscale import VideoUpscaleConfigOut, VideoUpscaleConfigSaveRequest
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.services.video_upscale.config_service import get_video_upscale_config, save_video_upscale_config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/video-upscale", tags=["admin-video-upscale"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=VideoUpscaleConfigOut, summary="获取视频超分配置")
|
||||||
|
async def get_config(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await get_video_upscale_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=VideoUpscaleConfigOut, summary="保存视频超分配置")
|
||||||
|
async def save_config(
|
||||||
|
req: VideoUpscaleConfigSaveRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
before = await get_video_upscale_config(db)
|
||||||
|
result = await save_video_upscale_config(db, req.data)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
"保存视频超分配置",
|
||||||
|
"PUT",
|
||||||
|
"/admin/video-upscale/config",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"before_version": before["data"].get("version"),
|
||||||
|
"after_version": result["data"].get("version"),
|
||||||
|
"enabled": result["data"].get("enabled"),
|
||||||
|
"delete_source_after_success": result["data"].get("delete_source_after_success"),
|
||||||
|
"rule_count": len(result["data"].get("rules") or []),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
+351
-283
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_db, get_admin_user
|
from app.dependencies import get_db, get_admin_user
|
||||||
@@ -28,48 +28,55 @@ from app.schemas.admin import (
|
|||||||
CreditAdjustRequest,
|
CreditAdjustRequest,
|
||||||
ModelConfigCreate,
|
ModelConfigCreate,
|
||||||
ModelConfigOut,
|
ModelConfigOut,
|
||||||
|
SystemConfigCreate,
|
||||||
SystemConfigUpdate,
|
SystemConfigUpdate,
|
||||||
SystemConfigOut,
|
SystemConfigOut,
|
||||||
AdminUserOut,
|
AdminUserOut,
|
||||||
AdminStatsOut,
|
AdminStatsOut,
|
||||||
|
DailyCreditOut,
|
||||||
|
TeamCreditOut,
|
||||||
|
ModelUsageOut,
|
||||||
|
VideoParamOut,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
UpdateMenusRequest,
|
UpdateMenusRequest,
|
||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
UpdateFrontendUserKindRequest,
|
UpdateFrontendUserKindRequest,
|
||||||
OperationLogOut,
|
|
||||||
AdminCreditRecordListOut,
|
|
||||||
)
|
)
|
||||||
from app.schemas.team import UpdateUserTeamRequest
|
from app.schemas.team import UpdateUserTeamRequest
|
||||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
from app.schemas.industry import IndustryConfigCreate
|
||||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||||
from app.services.credits import add_credits, deduct_credits
|
from app.services.credits import add_credits, deduct_credits
|
||||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
||||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||||
|
from app.services.system_config_cache import invalidate_system_config_cache
|
||||||
|
from app.services.llm_billing.config import validate_llm_system_config_value
|
||||||
from app.services.notification import create_notification
|
from app.services.notification import create_notification
|
||||||
from app.services.auth import hash_password, verify_password
|
from app.services.auth import hash_password, verify_password
|
||||||
from app.services.operation_log import log_operation
|
from app.services.operation_log import log_operation
|
||||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
from app.services.payment import sync_pending_orders, process_refund
|
from app.services.payment import process_refund
|
||||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||||
|
|
||||||
from app.services.generation_billing_service import (
|
|
||||||
OWNER_GENERATION_RECORD,
|
|
||||||
charge_generation_media_by_params,
|
|
||||||
get_next_credit_attempt_no,
|
|
||||||
)
|
|
||||||
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
|
||||||
from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot
|
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
|
||||||
|
|
||||||
|
|
||||||
CST = timezone(timedelta(hours=8))
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json_object(value: str | None) -> dict | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _iso(dt):
|
def _iso(dt):
|
||||||
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
|
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
|
||||||
if dt is None:
|
if dt is None:
|
||||||
@@ -483,7 +490,7 @@ async def admin_change_password(
|
|||||||
|
|
||||||
# ── Credit Records ───────────────────────────────────────
|
# ── Credit Records ───────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/credit-records", response_model=AdminCreditRecordListOut)
|
@router.get("/credit-records")
|
||||||
async def list_credit_records(
|
async def list_credit_records(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=1000),
|
page_size: int = Query(20, ge=1, le=1000),
|
||||||
@@ -497,15 +504,10 @@ async def list_credit_records(
|
|||||||
credit_subject: str | None = Query(None),
|
credit_subject: str | None = Query(None),
|
||||||
media_type: str | None = Query(None),
|
media_type: str | None = Query(None),
|
||||||
charge_kind: str | None = Query(None),
|
charge_kind: str | None = Query(None),
|
||||||
|
charge_action: str | None = Query(None),
|
||||||
source_module: str | None = Query(None),
|
source_module: str | None = Query(None),
|
||||||
source_step_code: str | None = Query(None),
|
source_step_code: str | None = Query(None),
|
||||||
billing_scene: str | None = Query(None),
|
billing_scene: str | None = Query(None),
|
||||||
engine_provider: str | None = Query(None),
|
|
||||||
engine_model_name: str | None = Query(None),
|
|
||||||
pricing_version_code: str | None = Query(None),
|
|
||||||
provider_cost_status: str | None = Query(None),
|
|
||||||
provider_cost_is_estimated: bool | None = Query(None),
|
|
||||||
has_attachment: bool | None = Query(None),
|
|
||||||
start_date: str = Query(None),
|
start_date: str = Query(None),
|
||||||
end_date: str = Query(None),
|
end_date: str = Query(None),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
@@ -525,15 +527,10 @@ async def list_credit_records(
|
|||||||
credit_subject=credit_subject,
|
credit_subject=credit_subject,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
charge_kind=charge_kind,
|
charge_kind=charge_kind,
|
||||||
|
charge_action=charge_action,
|
||||||
source_module=source_module,
|
source_module=source_module,
|
||||||
source_step_code=source_step_code,
|
source_step_code=source_step_code,
|
||||||
billing_scene=billing_scene,
|
billing_scene=billing_scene,
|
||||||
engine_provider=engine_provider,
|
|
||||||
engine_model_name=engine_model_name,
|
|
||||||
pricing_version_code=pricing_version_code,
|
|
||||||
provider_cost_status=provider_cost_status,
|
|
||||||
provider_cost_is_estimated=provider_cost_is_estimated,
|
|
||||||
has_attachment=has_attachment,
|
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
@@ -850,13 +847,14 @@ async def list_payment_orders(
|
|||||||
page_size: int = Query(20, ge=1, le=500),
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
payment_method: str | None = Query(None),
|
payment_method: str | None = Query(None),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
|
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
||||||
start_date: str | None = Query(None),
|
start_date: str | None = Query(None),
|
||||||
end_date: str | None = Query(None),
|
end_date: str | None = Query(None),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return paginated payment orders for admin dashboard."""
|
"""Return paginated payment orders for admin dashboard."""
|
||||||
query = select(PaymentOrder, User.username).join(User, PaymentOrder.user_id == User.id)
|
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
|
||||||
count_query = select(func.count(PaymentOrder.id))
|
count_query = select(func.count(PaymentOrder.id))
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
@@ -864,6 +862,8 @@ async def list_payment_orders(
|
|||||||
filters.append(PaymentOrder.payment_method == payment_method)
|
filters.append(PaymentOrder.payment_method == payment_method)
|
||||||
if status:
|
if status:
|
||||||
filters.append(PaymentOrder.status == status)
|
filters.append(PaymentOrder.status == status)
|
||||||
|
if phone:
|
||||||
|
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
|
||||||
if start_date:
|
if start_date:
|
||||||
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
||||||
if end_date:
|
if end_date:
|
||||||
@@ -887,6 +887,7 @@ async def list_payment_orders(
|
|||||||
"userId": o.user_id,
|
"userId": o.user_id,
|
||||||
"user_id": o.user_id,
|
"user_id": o.user_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
|
"phone": user_phone,
|
||||||
"amount": round(float(o.amount), 2),
|
"amount": round(float(o.amount), 2),
|
||||||
"credits": round(float(o.credits), 2),
|
"credits": round(float(o.credits), 2),
|
||||||
"paymentMethod": o.payment_method,
|
"paymentMethod": o.payment_method,
|
||||||
@@ -899,7 +900,7 @@ async def list_payment_orders(
|
|||||||
"createdAt": o.created_at.isoformat() if o.created_at else None,
|
"createdAt": o.created_at.isoformat() if o.created_at else None,
|
||||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||||
}
|
}
|
||||||
for o, username in rows
|
for o, username, user_phone in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
@@ -1125,11 +1126,15 @@ async def delete_industry_config(
|
|||||||
|
|
||||||
@router.get("/video-engines", response_model=list[VideoEngineOut])
|
@router.get("/video-engines", response_model=list[VideoEngineOut])
|
||||||
async def list_video_engines(
|
async def list_video_engines(
|
||||||
|
include_deleted: bool = Query(False),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
query = select(VideoEngine)
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.where(VideoEngine.deleted_at.is_(None))
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VideoEngine).order_by(VideoEngine.priority.desc())
|
query.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -1169,7 +1174,7 @@ async def update_video_engine(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)
|
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||||
)
|
)
|
||||||
engine = result.scalar_one_or_none()
|
engine = result.scalar_one_or_none()
|
||||||
if not engine:
|
if not engine:
|
||||||
@@ -1202,24 +1207,26 @@ async def delete_video_engine(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)
|
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||||
)
|
)
|
||||||
engine = result.scalar_one_or_none()
|
engine = result.scalar_one_or_none()
|
||||||
if not engine:
|
if not engine:
|
||||||
raise HTTPException(status_code=404, detail="视频引擎不存在")
|
raise HTTPException(status_code=404, detail="视频引擎不存在")
|
||||||
await db.delete(engine)
|
engine_name = engine.name
|
||||||
|
engine.deleted_at = datetime.now(timezone.utc)
|
||||||
|
engine.is_active = False
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
admin.username,
|
||||||
f"删除视频引擎: {engine.name}",
|
f"软删除视频引擎: {engine_name}",
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/admin/video-engines/{engine_id}",
|
f"/admin/video-engines/{engine_id}",
|
||||||
detail=json.dumps(
|
detail=json.dumps(
|
||||||
{
|
{
|
||||||
"engine_id": engine_id,
|
"engine_id": engine_id,
|
||||||
"name": engine.name,
|
"name": engine_name,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
@@ -1231,11 +1238,15 @@ async def delete_video_engine(
|
|||||||
|
|
||||||
@router.get("/image-engines", response_model=list[ImageEngineOut])
|
@router.get("/image-engines", response_model=list[ImageEngineOut])
|
||||||
async def list_image_engines(
|
async def list_image_engines(
|
||||||
|
include_deleted: bool = Query(False),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
query = select(ImageEngine)
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.where(ImageEngine.deleted_at.is_(None))
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ImageEngine).order_by(ImageEngine.priority.desc())
|
query.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -1275,7 +1286,7 @@ async def update_image_engine(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)
|
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||||
)
|
)
|
||||||
engine = result.scalar_one_or_none()
|
engine = result.scalar_one_or_none()
|
||||||
if not engine:
|
if not engine:
|
||||||
@@ -1308,24 +1319,26 @@ async def delete_image_engine(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)
|
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||||
)
|
)
|
||||||
engine = result.scalar_one_or_none()
|
engine = result.scalar_one_or_none()
|
||||||
if not engine:
|
if not engine:
|
||||||
raise HTTPException(status_code=404, detail="图片引擎不存在")
|
raise HTTPException(status_code=404, detail="图片引擎不存在")
|
||||||
await db.delete(engine)
|
engine_name = engine.name
|
||||||
|
engine.deleted_at = datetime.now(timezone.utc)
|
||||||
|
engine.is_active = False
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
admin.username,
|
||||||
f"删除图片引擎: {engine.name}",
|
f"软删除图片引擎: {engine_name}",
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/admin/image-engines/{engine_id}",
|
f"/admin/image-engines/{engine_id}",
|
||||||
detail=json.dumps(
|
detail=json.dumps(
|
||||||
{
|
{
|
||||||
"engine_id": engine_id,
|
"engine_id": engine_id,
|
||||||
"name": engine.name,
|
"name": engine_name,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
@@ -1350,7 +1363,7 @@ async def _validate_credit_ratio_engine(db: AsyncSession, req: CreditRatioCreate
|
|||||||
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
|
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
|
||||||
|
|
||||||
model = ImageEngine if gen_type == "image" else VideoEngine
|
model = ImageEngine if gen_type == "image" else VideoEngine
|
||||||
result = await db.execute(select(model).where(model.id == engine_id).limit(1))
|
result = await db.execute(select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1))
|
||||||
engine = result.scalar_one_or_none()
|
engine = result.scalar_one_or_none()
|
||||||
if not engine:
|
if not engine:
|
||||||
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
|
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
|
||||||
@@ -1496,10 +1509,14 @@ async def list_credit_ratios_grouped(
|
|||||||
|
|
||||||
@router.get("/model-configs", response_model=list[ModelConfigOut])
|
@router.get("/model-configs", response_model=list[ModelConfigOut])
|
||||||
async def list_model_configs(
|
async def list_model_configs(
|
||||||
|
include_deleted: bool = Query(False),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(ModelConfig).order_by(ModelConfig.priority.desc()))
|
query = select(ModelConfig)
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.where(ModelConfig.deleted_at.is_(None))
|
||||||
|
result = await db.execute(query.order_by(ModelConfig.priority.desc(), ModelConfig.id.desc()))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
@@ -1537,7 +1554,7 @@ async def update_model_config(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1))
|
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="配置不存在")
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
@@ -1568,23 +1585,25 @@ async def delete_model_config(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1))
|
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="配置不存在")
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
await db.delete(config)
|
config_name = config.name
|
||||||
|
config.deleted_at = datetime.now(timezone.utc)
|
||||||
|
config.is_active = False
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
admin.username,
|
||||||
f"删除模型配置: {config.name}",
|
f"软删除模型配置: {config_name}",
|
||||||
"DELETE",
|
"DELETE",
|
||||||
f"/admin/model-configs/{config_id}",
|
f"/admin/model-configs/{config_id}",
|
||||||
detail=json.dumps(
|
detail=json.dumps(
|
||||||
{
|
{
|
||||||
"config_id": config_id,
|
"config_id": config_id,
|
||||||
"name": config.name,
|
"name": config_name,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
@@ -1603,6 +1622,40 @@ async def list_system_configs(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system-configs", response_model=SystemConfigOut)
|
||||||
|
async def create_system_config(
|
||||||
|
req: SystemConfigCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
try:
|
||||||
|
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
config = SystemConfig(
|
||||||
|
id=generate_id(),
|
||||||
|
key=req.key,
|
||||||
|
value=str(req.value),
|
||||||
|
description=req.description or "",
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建系统配置: {config.key}",
|
||||||
|
"POST",
|
||||||
|
"/admin/system-configs",
|
||||||
|
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await invalidate_system_config_cache([req.key])
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
|
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
|
||||||
async def update_system_config(
|
async def update_system_config(
|
||||||
config_id: str,
|
config_id: str,
|
||||||
@@ -1614,6 +1667,10 @@ async def update_system_config(
|
|||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="配置不存在")
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
|
try:
|
||||||
|
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
config.value = str(req.value)
|
config.value = str(req.value)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
@@ -1632,7 +1689,10 @@ async def update_system_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
updated_key = str(config.key)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await invalidate_system_config_cache([updated_key])
|
||||||
|
await db.refresh(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -1669,6 +1729,15 @@ async def list_operation_logs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_param_out(model_map: dict[str, dict[str, int]]) -> list[VideoParamOut]:
|
||||||
|
"""将 {模型: {标签: 数量}} 转为扁平列表,按模型+数量排序。"""
|
||||||
|
result: list[VideoParamOut] = []
|
||||||
|
for model, labels in model_map.items():
|
||||||
|
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
|
||||||
|
result.append(VideoParamOut(model=model, label=label, count=count))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Stats ────────────────────────────────────────────────
|
# ── Stats ────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/stats", response_model=AdminStatsOut)
|
@router.get("/stats", response_model=AdminStatsOut)
|
||||||
@@ -1678,21 +1747,21 @@ async def get_stats(
|
|||||||
start_date: str = Query(None),
|
start_date: str = Query(None),
|
||||||
end_date: str = Query(None),
|
end_date: str = Query(None),
|
||||||
):
|
):
|
||||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if start_date:
|
if start_date:
|
||||||
date_start = datetime.strptime(start_date, "%Y-%m-%d")
|
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||||
else:
|
else:
|
||||||
date_start = today_start
|
date_start = today_start
|
||||||
if end_date:
|
if end_date:
|
||||||
date_end = datetime.strptime(end_date, "%Y-%m-%d")
|
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
|
||||||
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
else:
|
else:
|
||||||
date_end = datetime.now()
|
date_end = datetime.now(CST)
|
||||||
except:
|
except:
|
||||||
date_start = today_start
|
date_start = today_start
|
||||||
date_end = datetime.now()
|
date_end = datetime.now(CST)
|
||||||
|
|
||||||
total_users = (await db.execute(
|
total_users = (await db.execute(
|
||||||
select(func.count(User.id)).where(
|
select(func.count(User.id)).where(
|
||||||
@@ -1733,9 +1802,16 @@ async def get_stats(
|
|||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
|
||||||
|
real_credit_charge_filter = or_(
|
||||||
|
CreditRecord.charge_action.is_(None),
|
||||||
|
CreditRecord.charge_action == "charge",
|
||||||
|
)
|
||||||
|
|
||||||
credits_consumed = (await db.execute(
|
credits_consumed = (await db.execute(
|
||||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||||
CreditRecord.type == "consume",
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
CreditRecord.created_at >= date_start,
|
CreditRecord.created_at >= date_start,
|
||||||
CreditRecord.created_at <= date_end,
|
CreditRecord.created_at <= date_end,
|
||||||
)
|
)
|
||||||
@@ -1806,11 +1882,153 @@ async def get_stats(
|
|||||||
last_period_credits_consumed = (await db.execute(
|
last_period_credits_consumed = (await db.execute(
|
||||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||||
CreditRecord.type == "consume",
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
CreditRecord.created_at >= last_period_start,
|
CreditRecord.created_at >= last_period_start,
|
||||||
CreditRecord.created_at <= last_period_end,
|
CreditRecord.created_at <= last_period_end,
|
||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
|
||||||
|
from sqlalchemy import Date, cast as sa_cast
|
||||||
|
_day_expr = sa_cast(CreditRecord.created_at, Date)
|
||||||
|
# 图表固定展示 [date_end - 6天, date_end] 共7天
|
||||||
|
_chart_end_dt = date_end
|
||||||
|
_chart_start_dt = _chart_end_dt - timedelta(days=6)
|
||||||
|
_inner = (
|
||||||
|
select(
|
||||||
|
_day_expr.label('date'),
|
||||||
|
CreditRecord.source_module.label('module'),
|
||||||
|
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
|
CreditRecord.created_at >= _chart_start_dt,
|
||||||
|
CreditRecord.created_at <= _chart_end_dt,
|
||||||
|
)
|
||||||
|
.group_by(_day_expr, CreditRecord.source_module)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
daily_credits_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
_inner.c.date,
|
||||||
|
func.coalesce(_inner.c.module, 'other').label('module'),
|
||||||
|
_inner.c.credits,
|
||||||
|
).order_by(_inner.c.date)
|
||||||
|
)).all()
|
||||||
|
daily_credits_by_module = [
|
||||||
|
DailyCreditOut(date=str(row.date), module=row.module, credits=float(row.credits or 0))
|
||||||
|
for row in daily_credits_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 选中周期内各模块积分占比(按 source_module 分组,不拆日期)
|
||||||
|
_period_inner = (
|
||||||
|
select(
|
||||||
|
CreditRecord.source_module.label('module'),
|
||||||
|
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
|
CreditRecord.created_at >= date_start,
|
||||||
|
CreditRecord.created_at <= date_end,
|
||||||
|
)
|
||||||
|
.group_by(CreditRecord.source_module)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
period_credits_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(_period_inner.c.module, 'other').label('module'),
|
||||||
|
_period_inner.c.credits,
|
||||||
|
).order_by(_period_inner.c.credits.desc())
|
||||||
|
)).all()
|
||||||
|
period_credits_by_module = [
|
||||||
|
DailyCreditOut(date='', module=row.module, credits=float(row.credits or 0))
|
||||||
|
for row in period_credits_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
|
||||||
|
team_credit_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
|
||||||
|
CreditRecord.team_id_snapshot.label('team_id'),
|
||||||
|
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
|
CreditRecord.created_at >= date_start,
|
||||||
|
CreditRecord.created_at <= date_end,
|
||||||
|
)
|
||||||
|
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
|
||||||
|
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
|
||||||
|
)).all()
|
||||||
|
credits_by_team = [
|
||||||
|
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
|
||||||
|
for row in team_credit_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 各模型使用次数(通过 engine 快照字段统计)
|
||||||
|
model_usage_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(CreditRecord.engine_name, '未知').label('model_name'),
|
||||||
|
func.coalesce(CreditRecord.engine_provider, 'unknown').label('provider'),
|
||||||
|
func.count(CreditRecord.id).label('count'),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
|
CreditRecord.created_at >= date_start,
|
||||||
|
CreditRecord.created_at <= date_end,
|
||||||
|
)
|
||||||
|
.group_by(CreditRecord.engine_name, CreditRecord.engine_provider)
|
||||||
|
.order_by(func.count(CreditRecord.id).desc())
|
||||||
|
)).all()
|
||||||
|
model_usage = [
|
||||||
|
ModelUsageOut(model_name=row.model_name, provider=row.provider, count=int(row.count or 0))
|
||||||
|
for row in model_usage_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 视频分辨率/比例/时长使用分布(按模型分组)
|
||||||
|
_video_gen_q = (
|
||||||
|
select(
|
||||||
|
func.coalesce(CreditRecord.engine_name, '未知').label('model'),
|
||||||
|
ChatGenerationTask.resolution,
|
||||||
|
ChatGenerationTask.aspect_ratio,
|
||||||
|
ChatGenerationTask.duration,
|
||||||
|
)
|
||||||
|
.join(CreditRecord, CreditRecord.related_id == ChatGenerationTask.id)
|
||||||
|
.where(
|
||||||
|
ChatGenerationTask.gen_type == "video",
|
||||||
|
CreditRecord.type == "consume",
|
||||||
|
real_credit_charge_filter,
|
||||||
|
ChatGenerationTask.created_at >= date_start,
|
||||||
|
ChatGenerationTask.created_at <= date_end,
|
||||||
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_video_rows = (await db.execute(_video_gen_q)).all()
|
||||||
|
|
||||||
|
_res_map: dict[str, dict[str, int]] = {}
|
||||||
|
_ratio_map: dict[str, dict[str, int]] = {}
|
||||||
|
_dur_map: dict[str, dict[str, int]] = {}
|
||||||
|
for row in _video_rows:
|
||||||
|
model = row.model or '未知'
|
||||||
|
if row.resolution:
|
||||||
|
_res_map.setdefault(model, {})
|
||||||
|
_res_map[model][row.resolution] = _res_map[model].get(row.resolution, 0) + 1
|
||||||
|
if row.aspect_ratio:
|
||||||
|
_ratio_map.setdefault(model, {})
|
||||||
|
_ratio_map[model][row.aspect_ratio] = _ratio_map[model].get(row.aspect_ratio, 0) + 1
|
||||||
|
if row.duration:
|
||||||
|
_k = f"{row.duration}秒"
|
||||||
|
_dur_map.setdefault(model, {})
|
||||||
|
_dur_map[model][_k] = _dur_map[model].get(_k, 0) + 1
|
||||||
|
|
||||||
|
video_resolution_usage = _build_param_out(_res_map)
|
||||||
|
video_ratio_usage = _build_param_out(_ratio_map)
|
||||||
|
video_duration_usage = _build_param_out(_dur_map)
|
||||||
|
|
||||||
return AdminStatsOut(
|
return AdminStatsOut(
|
||||||
total_users=total_users,
|
total_users=total_users,
|
||||||
total_projects=total_projects,
|
total_projects=total_projects,
|
||||||
@@ -1826,6 +2044,13 @@ async def get_stats(
|
|||||||
last_period_records=last_period_records,
|
last_period_records=last_period_records,
|
||||||
last_period_revenue=float(last_period_revenue),
|
last_period_revenue=float(last_period_revenue),
|
||||||
last_period_credits_consumed=float(last_period_credits_consumed),
|
last_period_credits_consumed=float(last_period_credits_consumed),
|
||||||
|
daily_credits_by_module=daily_credits_by_module,
|
||||||
|
period_credits_by_module=period_credits_by_module,
|
||||||
|
credits_by_team=credits_by_team,
|
||||||
|
model_usage=model_usage,
|
||||||
|
video_resolution_usage=video_resolution_usage,
|
||||||
|
video_ratio_usage=video_ratio_usage,
|
||||||
|
video_duration_usage=video_duration_usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1875,6 +2100,8 @@ async def list_token_usage(
|
|||||||
async def admin_list_generation_records(
|
async def admin_list_generation_records(
|
||||||
user_id: str | None = Query(None),
|
user_id: str | None = Query(None),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
|
engine_id: str | None = Query(None),
|
||||||
|
include_media_references: bool | None = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=500),
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
@@ -1893,13 +2120,25 @@ async def admin_list_generation_records(
|
|||||||
query = query.where(GenerationRecord.user_id == user_id)
|
query = query.where(GenerationRecord.user_id == user_id)
|
||||||
if status:
|
if status:
|
||||||
query = query.where(GenerationRecord.status == status)
|
query = query.where(GenerationRecord.status == status)
|
||||||
|
if engine_id:
|
||||||
|
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||||
|
if include_media_references is not None:
|
||||||
|
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||||
|
|
||||||
# Count total
|
# Count total
|
||||||
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))
|
count_query = (
|
||||||
|
select(func.count(GenerationRecord.id))
|
||||||
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
|
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
|
||||||
|
)
|
||||||
if user_id:
|
if user_id:
|
||||||
count_query = count_query.where(GenerationRecord.user_id == user_id)
|
count_query = count_query.where(GenerationRecord.user_id == user_id)
|
||||||
if status:
|
if status:
|
||||||
count_query = count_query.where(GenerationRecord.status == status)
|
count_query = count_query.where(GenerationRecord.status == status)
|
||||||
|
if engine_id:
|
||||||
|
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||||
|
if include_media_references is not None:
|
||||||
|
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||||
total_result = await db.execute(count_query)
|
total_result = await db.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
@@ -1930,9 +2169,15 @@ async def admin_list_generation_records(
|
|||||||
"aspect_ratio": record.aspect_ratio,
|
"aspect_ratio": record.aspect_ratio,
|
||||||
"resolution": record.resolution,
|
"resolution": record.resolution,
|
||||||
"status": record.status,
|
"status": record.status,
|
||||||
|
"pipeline_stage": record.pipeline_stage,
|
||||||
|
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
|
||||||
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
|
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
|
||||||
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||||
"references": refs,
|
"references": refs,
|
||||||
|
"engine_id": record.engine_id,
|
||||||
|
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
|
||||||
|
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
|
||||||
|
"include_media_references": bool(record.include_media_references),
|
||||||
"credits_cost": record.credits_cost or 0,
|
"credits_cost": record.credits_cost or 0,
|
||||||
"text_credits_cost": record.text_credits_cost or 0,
|
"text_credits_cost": record.text_credits_cost or 0,
|
||||||
"text_tokens_used": record.text_tokens_used or 0,
|
"text_tokens_used": record.text_tokens_used or 0,
|
||||||
@@ -1953,233 +2198,6 @@ async def admin_list_generation_records(
|
|||||||
return {"total": total, "items": items}
|
return {"total": total, "items": items}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/generation-records/{record_id}/status")
|
|
||||||
async def admin_update_generation_status(
|
|
||||||
record_id: str,
|
|
||||||
body: dict,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Admin update generation record status (e.g., confirm/reject)."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(GenerationRecord).where(
|
|
||||||
GenerationRecord.id == record_id,
|
|
||||||
GenerationRecord.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
record = result.scalar_one_or_none()
|
|
||||||
if not record:
|
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
|
||||||
|
|
||||||
new_status = body.get("status")
|
|
||||||
if new_status not in ("prompt_optimized", "generating", "completed", "failed"):
|
|
||||||
raise HTTPException(status_code=400, detail="无效状态")
|
|
||||||
|
|
||||||
if new_status == "failed":
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
|
||||||
record=record,
|
|
||||||
error_message=body.get("error_message") or record.error_message or "管理员设置为失败",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record.status = new_status
|
|
||||||
|
|
||||||
if body.get("video_url"):
|
|
||||||
record.video_url = body["video_url"]
|
|
||||||
if body.get("video_cover_url"):
|
|
||||||
record.video_cover_url = body["video_cover_url"]
|
|
||||||
if body.get("image_url"):
|
|
||||||
record.image_url = body["image_url"]
|
|
||||||
if new_status == "completed":
|
|
||||||
record.generated_at = datetime.now()
|
|
||||||
await db.flush()
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin.id,
|
|
||||||
admin.username,
|
|
||||||
f"更新生成记录状态: {new_status}",
|
|
||||||
"PUT",
|
|
||||||
f"/admin/generation-records/{record_id}/status",
|
|
||||||
detail=json.dumps(
|
|
||||||
{
|
|
||||||
"record_id": record_id,
|
|
||||||
"new_status": new_status,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {"message": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generation-records/{record_id}/generate")
|
|
||||||
async def admin_generate_video(
|
|
||||||
record_id: str,
|
|
||||||
body: dict,
|
|
||||||
admin: User = Depends(get_admin_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Admin trigger video/image generation for a record with specified params."""
|
|
||||||
from app.models.project import Project
|
|
||||||
from app.services.video_queue import task_queue
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(GenerationRecord, Project.name)
|
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
GenerationRecord.id == record_id,
|
|
||||||
GenerationRecord.deleted_at.is_(None),
|
|
||||||
Project.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
|
||||||
|
|
||||||
record, project_name = row
|
|
||||||
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
|
|
||||||
|
|
||||||
if record.status not in ("prompt_optimized", "failed"):
|
|
||||||
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
|
|
||||||
|
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
|
||||||
db,
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
owner_id=record.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if record.gen_type == GenerationType.video:
|
|
||||||
# Video Generation
|
|
||||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
|
||||||
resolution = body.get("resolution", "720p")
|
|
||||||
if aspect_ratio not in ASPECT_RATIOS:
|
|
||||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
|
||||||
if resolution not in RESOLUTIONS:
|
|
||||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
|
||||||
|
|
||||||
duration = record.duration or 5
|
|
||||||
from app.services.video_gen import get_active_engine, submit_video_task
|
|
||||||
engine = await get_active_engine(db)
|
|
||||||
engine_snapshot = _build_video_snapshot(engine, aspect_ratio, resolution, duration)
|
|
||||||
media_billing = await charge_generation_media_by_params(
|
|
||||||
db,
|
|
||||||
user_id=record.user_id,
|
|
||||||
record_id=record.id,
|
|
||||||
gen_type="video",
|
|
||||||
duration=duration,
|
|
||||||
resolution=resolution,
|
|
||||||
aspect_ratio=aspect_ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
|
||||||
description_prefix="视频生成(管理后台)",
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
|
||||||
|
|
||||||
record.aspect_ratio = aspect_ratio
|
|
||||||
record.resolution = resolution
|
|
||||||
record.engine_id = engine.id
|
|
||||||
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
|
|
||||||
record.current_billing_attempt_no = attempt_no
|
|
||||||
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
||||||
record.status = "generating"
|
|
||||||
record.error_message = None
|
|
||||||
record.video_url = None
|
|
||||||
record.video_cover_url = None
|
|
||||||
record.image_url = None
|
|
||||||
record.seedance_task_id = None
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
task_id = await submit_video_task(
|
|
||||||
db,
|
|
||||||
engine,
|
|
||||||
record,
|
|
||||||
include_media_references=False,
|
|
||||||
)
|
|
||||||
record.seedance_task_id = task_id
|
|
||||||
await db.flush()
|
|
||||||
await task_queue.enqueue(record_id)
|
|
||||||
except Exception as e:
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
|
||||||
record=record,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
elif record.gen_type == GenerationType.image:
|
|
||||||
# Image generation
|
|
||||||
|
|
||||||
post_image_size = body.get("image_size", "")
|
|
||||||
image_size = post_image_size or record.image_size or "2K"
|
|
||||||
from app.services.image_gen import get_active_image_engine
|
|
||||||
engine = await get_active_image_engine(db)
|
|
||||||
image_proportion = record.image_proportion or "1:1"
|
|
||||||
image_px = record.image_px or "2048x2048"
|
|
||||||
engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px)
|
|
||||||
media_billing = await charge_generation_media_by_params(
|
|
||||||
db,
|
|
||||||
user_id=record.user_id,
|
|
||||||
record_id=record.id,
|
|
||||||
gen_type="image",
|
|
||||||
image_size=image_size,
|
|
||||||
image_px=image_px,
|
|
||||||
aspect_ratio=image_proportion,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
|
||||||
description_prefix="图片生成(管理后台)",
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
|
||||||
|
|
||||||
record.image_size = image_size
|
|
||||||
record.engine_id = engine.id
|
|
||||||
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
|
|
||||||
record.current_billing_attempt_no = attempt_no
|
|
||||||
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
||||||
record.status = "generating"
|
|
||||||
record.error_message = None
|
|
||||||
record.image_url = None
|
|
||||||
record.video_url = None
|
|
||||||
record.video_cover_url = None
|
|
||||||
record.seedance_task_id = None
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await task_queue.enqueue(record_id)
|
|
||||||
except Exception as e:
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
|
||||||
record=record,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
await log_operation(
|
|
||||||
db,
|
|
||||||
admin.id,
|
|
||||||
admin.username,
|
|
||||||
f"管理员触发生成{type_str}: {record_id}",
|
|
||||||
"POST",
|
|
||||||
f"/admin/generation-records/{record_id}/generate",
|
|
||||||
detail=json.dumps(
|
|
||||||
{
|
|
||||||
"record_id": record_id,
|
|
||||||
"gen_type": record.gen_type,
|
|
||||||
"project_name": project_name,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {"message": "ok", "record_id": record_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ── File Uploads ─────────────────────────────────────────
|
# ── File Uploads ─────────────────────────────────────────
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -2195,7 +2213,6 @@ async def upload_pdf(
|
|||||||
):
|
):
|
||||||
"""Upload a PDF file and save URL to system config."""
|
"""Upload a PDF file and save URL to system config."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="请选择文件")
|
raise HTTPException(status_code=400, detail="请选择文件")
|
||||||
@@ -2257,7 +2274,6 @@ async def upload_logo(
|
|||||||
):
|
):
|
||||||
"""Upload a Logo image file and save URL to system config."""
|
"""Upload a Logo image file and save URL to system config."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="请选择文件")
|
raise HTTPException(status_code=400, detail="请选择文件")
|
||||||
@@ -2311,6 +2327,58 @@ async def upload_logo(
|
|||||||
return {"url": url}
|
return {"url": url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-login-video")
|
||||||
|
async def upload_login_video(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择文件")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 50 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
|
||||||
|
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
safe_name = f"login_bg_{generate_id()}{ext}"
|
||||||
|
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
url = f"/uploads/{safe_name}"
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if config:
|
||||||
|
config.value = url
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(
|
||||||
|
id=generate_id(),
|
||||||
|
key="login_bg_video",
|
||||||
|
value=url,
|
||||||
|
description="登录页背景视频",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"上传登录背景视频: {file.filename}",
|
||||||
|
"POST",
|
||||||
|
"/admin/upload-login-video",
|
||||||
|
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"url": url}
|
||||||
|
|
||||||
|
|
||||||
# ── Payment Stats ────────────────────────────────────────
|
# ── Payment Stats ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -15,6 +17,7 @@ from app.models.system_config import SystemConfig
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
|
ChangeUsernameRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
SetPasswordRequest,
|
SetPasswordRequest,
|
||||||
@@ -117,7 +120,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
|||||||
if credits <= 0:
|
if credits <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
today = datetime.now().date()
|
today = datetime.now(CST).date()
|
||||||
if user.last_login_at:
|
if user.last_login_at:
|
||||||
last_login_date = user.last_login_at.date()
|
last_login_date = user.last_login_at.date()
|
||||||
if last_login_date >= today:
|
if last_login_date >= today:
|
||||||
@@ -159,7 +162,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await _handle_daily_login_credits(db, user)
|
await _handle_daily_login_credits(db, user)
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now(CST)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return _token_response(user, req.remember_me)
|
return _token_response(user, req.remember_me)
|
||||||
|
|
||||||
@@ -190,7 +193,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await _handle_daily_login_credits(db, user)
|
await _handle_daily_login_credits(db, user)
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now(CST)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return _token_response(user, req.remember_me)
|
return _token_response(user, req.remember_me)
|
||||||
|
|
||||||
@@ -222,7 +225,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
username=req.phone,
|
username=req.phone,
|
||||||
phone=req.phone,
|
phone=req.phone,
|
||||||
hashed_password=hash_password(req.password),
|
hashed_password=hash_password(req.password),
|
||||||
password_set_at=datetime.now(),
|
password_set_at=datetime.now(CST),
|
||||||
credits=register_credits,
|
credits=register_credits,
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
user_type="frontend",
|
user_type="frontend",
|
||||||
@@ -294,7 +297,7 @@ async def set_password(
|
|||||||
)
|
)
|
||||||
|
|
||||||
current_user.hashed_password = hash_password(req.new_password)
|
current_user.hashed_password = hash_password(req.new_password)
|
||||||
current_user.password_set_at = datetime.now()
|
current_user.password_set_at = datetime.now(CST)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"message": "密码设置成功", "must_set_password": False}
|
return {"message": "密码设置成功", "must_set_password": False}
|
||||||
|
|
||||||
@@ -318,17 +321,28 @@ async def change_password(
|
|||||||
)
|
)
|
||||||
|
|
||||||
current_user.hashed_password = hash_password(req.new_password)
|
current_user.hashed_password = hash_password(req.new_password)
|
||||||
current_user.password_set_at = datetime.now()
|
current_user.password_set_at = datetime.now(CST)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"message": "密码修改成功"}
|
return {"message": "密码修改成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-username")
|
||||||
|
async def change_username(
|
||||||
|
req: ChangeUsernameRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_user.username = req.new_username.strip()
|
||||||
|
await db.flush()
|
||||||
|
return {"message": "用户名修改成功"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/site-info")
|
@router.get("/site-info")
|
||||||
async def get_site_info(db: AsyncSession = Depends(get_db)):
|
async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||||
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemConfig).where(SystemConfig.key.in_([
|
select(SystemConfig).where(SystemConfig.key.in_([
|
||||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual"
|
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
|
||||||
]))
|
]))
|
||||||
)
|
)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
@@ -346,11 +360,13 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
|||||||
return f"{base_url}{path}"
|
return f"{base_url}{path}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site_name": info.get("site_name", "VideoGen.AI"),
|
"site_name": info.get("site_name", "智创"),
|
||||||
"site_logo": to_full_url(info.get("site_logo")),
|
"site_logo": to_full_url(info.get("site_logo")),
|
||||||
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
|
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
|
||||||
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
|
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
|
||||||
"operation_manual": info.get("operation_manual", ""),
|
"operation_manual": info.get("operation_manual", ""),
|
||||||
|
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
||||||
|
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -370,7 +386,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
detail="该账号不是管理员账号",
|
detail="该账号不是管理员账号",
|
||||||
)
|
)
|
||||||
|
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now(CST)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
token = create_access_token(user.id, req.remember_me)
|
token = create_access_token(user.id, req.remember_me)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.contact_request import ContactRequest
|
from app.models.contact_request import ContactRequest
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.contact import ContactRequestCreate, ContactRequestListOut, ContactRequestOut
|
from app.schemas.contact import ContactRequestCreate
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/contact", tags=["contact"])
|
router = APIRouter(prefix="/contact", tags=["contact"])
|
||||||
@@ -63,93 +63,3 @@ async def create_contact_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"message": "提交成功,我们会尽快与您联系"}
|
return {"message": "提交成功,我们会尽快与您联系"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests", summary="获取联系请求列表", response_model=ContactRequestListOut)
|
|
||||||
async def get_contact_requests(
|
|
||||||
page: int = 1,
|
|
||||||
page_size: int = 20,
|
|
||||||
is_handled: bool | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user.is_admin:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
|
||||||
|
|
||||||
query = select(ContactRequest)
|
|
||||||
count_query = select(func.count(ContactRequest.id))
|
|
||||||
|
|
||||||
if is_handled is not None:
|
|
||||||
query = query.where(ContactRequest.is_handled == is_handled)
|
|
||||||
count_query = count_query.where(ContactRequest.is_handled == is_handled)
|
|
||||||
|
|
||||||
query = query.order_by(ContactRequest.created_at.desc())
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
result = await db.execute(query.offset(offset).limit(page_size))
|
|
||||||
items = result.scalars().all()
|
|
||||||
|
|
||||||
total = (await db.execute(count_query)).scalar_one()
|
|
||||||
|
|
||||||
return {"items": items, "total": total}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}", summary="获取联系请求详情", response_model=ContactRequestOut)
|
|
||||||
async def get_contact_request(
|
|
||||||
request_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user.is_admin:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
|
||||||
|
|
||||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
|
||||||
contact_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not contact_request:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
|
||||||
|
|
||||||
return contact_request
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/requests/{request_id}/handle", summary="标记为已处理")
|
|
||||||
async def mark_as_handled(
|
|
||||||
request_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user.is_admin:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
|
||||||
|
|
||||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
|
||||||
contact_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not contact_request:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
|
||||||
|
|
||||||
contact_request.is_handled = True
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(contact_request)
|
|
||||||
|
|
||||||
return {"message": "已标记为处理"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/requests/{request_id}", summary="删除联系请求")
|
|
||||||
async def delete_contact_request(
|
|
||||||
request_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user.is_admin:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
|
||||||
|
|
||||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
|
||||||
contact_request = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not contact_request:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
|
||||||
|
|
||||||
await db.delete(contact_request)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
@@ -62,14 +62,14 @@ async def get_credit_ratios(
|
|||||||
|
|
||||||
video_engines_result = await db.execute(
|
video_engines_result = await db.execute(
|
||||||
select(VideoEngine.id)
|
select(VideoEngine.id)
|
||||||
.where(VideoEngine.is_active == True)
|
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||||
.order_by(VideoEngine.priority.desc())
|
.order_by(VideoEngine.priority.desc())
|
||||||
)
|
)
|
||||||
video_engine_ids = video_engines_result.scalars().all()
|
video_engine_ids = video_engines_result.scalars().all()
|
||||||
|
|
||||||
image_engines_result = await db.execute(
|
image_engines_result = await db.execute(
|
||||||
select(ImageEngine.id)
|
select(ImageEngine.id)
|
||||||
.where(ImageEngine.is_active == True)
|
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||||
.order_by(ImageEngine.priority.desc())
|
.order_by(ImageEngine.priority.desc())
|
||||||
)
|
)
|
||||||
image_engine_ids = image_engines_result.scalars().all()
|
image_engine_ids = image_engines_result.scalars().all()
|
||||||
|
|||||||
@@ -1,69 +1,129 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import timezone, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.schemas.generation import (
|
from app.schemas.generation import (
|
||||||
OptimizeParams,
|
OptimizeParams,
|
||||||
GenerateParams,
|
|
||||||
GenerationRecordOut,
|
GenerationRecordOut,
|
||||||
GenerationRecordPageListOut,
|
GenerationRecordPageListOut,
|
||||||
OptimizeResult,
|
OptimizeResult,
|
||||||
UpdatePromptRequest,
|
UpdatePromptRequest,
|
||||||
GenerationType,
|
|
||||||
DURATIONS,
|
|
||||||
ASPECT_RATIOS,
|
|
||||||
RESOLUTIONS,
|
|
||||||
IMAGE_SIZES,
|
|
||||||
)
|
)
|
||||||
from app.services.credits import deduct_credits, calc_text_credits
|
from app.services.generation.pipeline.db_lock_service import (
|
||||||
from app.services.llm import optimize_prompt
|
DatabaseRowLockBusy,
|
||||||
from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url
|
execute_with_lock_timeout,
|
||||||
from app.services.resource_accounting_service import (
|
|
||||||
record_generation_record_generated_resource,
|
|
||||||
safe_file_size,
|
|
||||||
)
|
)
|
||||||
|
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
||||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||||
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
||||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
|
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||||
from app.services.generation_billing_service import (
|
from app.enums.generation_status import (
|
||||||
CHARGE_TEXT_PROMPT,
|
ASPECT_RATIOS,
|
||||||
|
DURATIONS,
|
||||||
|
IMAGE_SIZES,
|
||||||
|
RESOLUTIONS,
|
||||||
|
GenerationRecordPipelineStage,
|
||||||
|
GenerationType,
|
||||||
|
)
|
||||||
|
from app.enums.common import LogEventStatusEnum
|
||||||
|
from app.enums.generation_record import (
|
||||||
|
GenerationRecordConfigSourceEnum,
|
||||||
|
GenerationRecordEventTypeEnum,
|
||||||
|
)
|
||||||
|
from app.services.generation.billing_service import (
|
||||||
OWNER_GENERATION_RECORD,
|
OWNER_GENERATION_RECORD,
|
||||||
build_credit_biz_key,
|
|
||||||
charge_generation_media_by_params,
|
|
||||||
charge_generation_media_for_record,
|
charge_generation_media_for_record,
|
||||||
get_next_credit_attempt_no,
|
get_next_credit_attempt_no,
|
||||||
)
|
)
|
||||||
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
from app.services.generation.ai.engine_service import (
|
||||||
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
get_image_engine,
|
||||||
from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot
|
get_video_engine,
|
||||||
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
)
|
||||||
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
from app.services.generation.pipeline.generation_record_config_service import (
|
||||||
|
ensure_generation_record_config_frozen,
|
||||||
|
frozen_generation_record_engine_view,
|
||||||
|
generation_record_config_fallback_hint,
|
||||||
|
generation_record_engine_snapshot,
|
||||||
|
is_generation_record_config_complete,
|
||||||
|
is_generation_record_config_recoverable,
|
||||||
|
log_generation_record_config_event,
|
||||||
|
)
|
||||||
|
from app.services.generation.media_reference_service import (
|
||||||
|
calculate_media_reference_usage,
|
||||||
|
validate_media_reference_usage_for_engine,
|
||||||
|
)
|
||||||
|
from app.services.generation.prompt_optimize_service import optimize_generation_prompt
|
||||||
from app.enums.audio_reference import (
|
from app.enums.audio_reference import (
|
||||||
AUDIO_ALLOWED_EXTENSIONS,
|
AUDIO_ALLOWED_EXTENSIONS,
|
||||||
AUDIO_ALLOWED_MIME_TYPES,
|
AUDIO_ALLOWED_MIME_TYPES,
|
||||||
AUDIO_MAX_FILE_SIZE_MB,
|
AUDIO_MAX_FILE_SIZE_MB,
|
||||||
)
|
)
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.exceptions import RecordNotFoundError, InvalidStatusError
|
||||||
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/generation-records", tags=["generation"])
|
router = APIRouter(prefix="/generation-records", tags=["generation"])
|
||||||
logger = logging.getLogger("videogen")
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_snapshot(record: GenerationRecord) -> dict | None:
|
||||||
|
return generation_record_engine_snapshot(record)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_config_complete(record: GenerationRecord) -> bool:
|
||||||
|
return is_generation_record_config_complete(record)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_status_view(record: GenerationRecord) -> dict[str, object]:
|
||||||
|
config_complete = _record_config_complete(record)
|
||||||
|
config_recoverable = is_generation_record_config_recoverable(record)
|
||||||
|
prompt_failure = record.status == "failed" and record.resource_generation_started_at is None
|
||||||
|
resource_failure = record.status == "failed" and record.resource_generation_started_at is not None
|
||||||
|
if record.status in {"pending", "optimizing", "settlement_pending"}:
|
||||||
|
client_status = "prompt_processing"
|
||||||
|
operation_phase = "prompt"
|
||||||
|
elif record.status == "prompt_optimized":
|
||||||
|
client_status = "ready"
|
||||||
|
operation_phase = "prompt"
|
||||||
|
elif record.status == "generating":
|
||||||
|
client_status = "generating"
|
||||||
|
operation_phase = "resource"
|
||||||
|
elif record.status == "completed":
|
||||||
|
client_status = "success"
|
||||||
|
operation_phase = "resource"
|
||||||
|
else:
|
||||||
|
client_status = "failure"
|
||||||
|
operation_phase = "prompt" if prompt_failure else "resource"
|
||||||
|
return {
|
||||||
|
"config_complete": config_complete,
|
||||||
|
"config_recoverable": config_recoverable,
|
||||||
|
"config_fallback_hint": generation_record_config_fallback_hint(record),
|
||||||
|
"can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable),
|
||||||
|
"can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value,
|
||||||
|
"should_poll": record.status in {"optimizing", "settlement_pending", "generating"},
|
||||||
|
"client_status": client_status,
|
||||||
|
"operation_phase": operation_phase,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_engine_view(record: GenerationRecord) -> SimpleNamespace:
|
||||||
|
return frozen_generation_record_engine_view(record)
|
||||||
|
|
||||||
|
|
||||||
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||||
refs = refs_override
|
refs = refs_override
|
||||||
if refs is None and record.media_references:
|
if refs is None and record.media_references:
|
||||||
@@ -100,15 +160,22 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
|
|||||||
image_proportion=record.image_proportion,
|
image_proportion=record.image_proportion,
|
||||||
image_px=record.image_px,
|
image_px=record.image_px,
|
||||||
status=record.status,
|
status=record.status,
|
||||||
|
pipeline_stage=record.pipeline_stage,
|
||||||
|
video_upscale_enabled=bool(record.video_upscale_enabled_snapshot),
|
||||||
video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
|
video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
|
||||||
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||||
image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
|
image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
|
||||||
references=refs,
|
references=refs,
|
||||||
|
engine_id=record.engine_id,
|
||||||
|
engine_name=(_engine_snapshot(record) or {}).get("name"),
|
||||||
|
engine_snapshot=_engine_snapshot(record),
|
||||||
|
include_media_references=bool(record.include_media_references),
|
||||||
|
**_record_status_view(record),
|
||||||
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
|
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
|
||||||
# text_tokens_used=record.text_tokens_used or 0,
|
text_tokens_used=record.text_tokens_used or 0,
|
||||||
credits_cost=round(record.credits_cost or 0.00, 2),
|
credits_cost=round(record.credits_cost or 0.00, 2),
|
||||||
# video_tokens_used=record.video_tokens_used or 0,
|
video_tokens_used=record.video_tokens_used or 0,
|
||||||
# image_tokens_used=record.image_tokens_used or 0,
|
image_tokens_used=record.image_tokens_used or 0,
|
||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
created_at=record.created_at,
|
created_at=record.created_at,
|
||||||
generated_at=record.generated_at,
|
generated_at=record.generated_at,
|
||||||
@@ -149,6 +216,8 @@ async def list_records(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
allowed_statuses = {
|
allowed_statuses = {
|
||||||
|
"optimizing",
|
||||||
|
"settlement_pending",
|
||||||
"prompt_optimized",
|
"prompt_optimized",
|
||||||
"generating",
|
"generating",
|
||||||
"failed",
|
"failed",
|
||||||
@@ -158,7 +227,7 @@ async def list_records(
|
|||||||
if status and status not in allowed_statuses:
|
if status and status not in allowed_statuses:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed",
|
detail="状态参数错误,仅支持:optimizing、settlement_pending、prompt_optimized、generating、failed、completed",
|
||||||
)
|
)
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@@ -218,324 +287,217 @@ async def optimize(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
record = None
|
user_id_snapshot = str(current_user.id)
|
||||||
# Validate parameters based on generation type
|
service_result = await optimize_generation_prompt(
|
||||||
if req.gen_type == GenerationType.video:
|
db,
|
||||||
if req.duration not in DURATIONS:
|
req=req,
|
||||||
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
|
user_id=user_id_snapshot,
|
||||||
if not req.duration:
|
)
|
||||||
raise HTTPException(status_code=400, detail="视频生成需要指定时长")
|
refreshed = await db.execute(
|
||||||
elif req.gen_type == GenerationType.image:
|
|
||||||
if req.image_size not in IMAGE_SIZES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
|
|
||||||
if not req.image_size:
|
|
||||||
raise HTTPException(status_code=400, detail="图片生成需要指定画面分辨率")
|
|
||||||
|
|
||||||
# Idempotency check: if key provided, return existing record if found
|
|
||||||
if req.idempotency_key:
|
|
||||||
existing = await db.execute(
|
|
||||||
select(GenerationRecord, Project.name)
|
select(GenerationRecord, Project.name)
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
GenerationRecord.user_id == current_user.id,
|
GenerationRecord.id == service_result.record_id,
|
||||||
|
GenerationRecord.user_id == user_id_snapshot,
|
||||||
GenerationRecord.deleted_at.is_(None),
|
GenerationRecord.deleted_at.is_(None),
|
||||||
Project.deleted_at.is_(None),
|
Project.deleted_at.is_(None),
|
||||||
GenerationRecord.idempotency_key == req.idempotency_key,
|
|
||||||
GenerationRecord.gen_type == req.gen_type,
|
|
||||||
GenerationRecord.status == "prompt_optimized",
|
|
||||||
)
|
)
|
||||||
.order_by(GenerationRecord.created_at.desc())
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
row = existing.first()
|
refreshed_row = refreshed.first()
|
||||||
if row:
|
if not refreshed_row:
|
||||||
record, project_name = row
|
raise RecordNotFoundError()
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
record, project_name = refreshed_row
|
||||||
|
refs = await resolve_private_portrait_reference_display_urls(
|
||||||
|
db,
|
||||||
|
json.loads(record.media_references) if record.media_references else None,
|
||||||
|
user_id=user_id_snapshot,
|
||||||
|
)
|
||||||
return OptimizeResult(
|
return OptimizeResult(
|
||||||
optimized_prompt=record.optimized_prompt or "",
|
optimized_prompt=record.optimized_prompt or "",
|
||||||
text_credits_cost=record.text_credits_cost or 0.00,
|
text_credits_cost=round(record.text_credits_cost or 0, 2),
|
||||||
text_tokens_used=record.text_tokens_used or 0,
|
text_tokens_used=record.text_tokens_used or 0,
|
||||||
record=_record_to_out(record, project_name, refs_override=refs),
|
record=_record_to_out(record, project_name, refs_override=refs),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check project exists and belongs to user
|
|
||||||
proj_result = await db.execute(
|
|
||||||
select(Project).where(
|
|
||||||
Project.id == req.project_id,
|
|
||||||
Project.user_id == current_user.id,
|
|
||||||
Project.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = proj_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
|
||||||
|
|
||||||
# Optimize prompt via LLM with type-specific context
|
|
||||||
try:
|
|
||||||
optimized, token_usage = await optimize_prompt(
|
|
||||||
db, req.prompt,
|
|
||||||
user_id=current_user.id,
|
|
||||||
industry_key=project.industry,
|
|
||||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
||||||
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
||||||
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
||||||
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
||||||
references=req.references,
|
|
||||||
gen_type=req.gen_type,
|
|
||||||
)
|
|
||||||
# Create record BEFORE LLM call so it's visible if user refreshes
|
|
||||||
record = GenerationRecord(
|
|
||||||
id=generate_id(),
|
|
||||||
user_id=current_user.id,
|
|
||||||
project_id=req.project_id,
|
|
||||||
original_prompt=req.prompt,
|
|
||||||
gen_type=req.gen_type,
|
|
||||||
duration=req.duration,
|
|
||||||
image_size=req.image_size,
|
|
||||||
image_proportion=req.image_proportion,
|
|
||||||
image_px=req.image_px,
|
|
||||||
status="optimizing",
|
|
||||||
credits_cost=0,
|
|
||||||
text_credits_cost=0,
|
|
||||||
text_tokens_used=0,
|
|
||||||
media_references=json.dumps(req.references) if req.references else None,
|
|
||||||
idempotency_key=req.idempotency_key,
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
await db.flush()
|
|
||||||
await db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
from app.services.error_codes import extract_error_message
|
|
||||||
if record:
|
|
||||||
record.status = "failed"
|
|
||||||
record.error_message = extract_error_message(e, "提示词")
|
|
||||||
await db.flush()
|
|
||||||
await db.commit()
|
|
||||||
error_message = extract_error_message(e, "提示词")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"AI模型调用失败: {error_message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
text_credits = await calc_text_credits(
|
|
||||||
db, token_usage["input_tokens"], token_usage["output_tokens"],
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_record_id = record.id
|
|
||||||
failed_user_id = current_user.id
|
|
||||||
try:
|
|
||||||
prompt_attempt_no = 1
|
|
||||||
prompt_biz_key = build_credit_biz_key(
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
owner_id=record.id,
|
|
||||||
attempt_no=prompt_attempt_no,
|
|
||||||
charge_kind=CHARGE_TEXT_PROMPT,
|
|
||||||
action="charge",
|
|
||||||
)
|
|
||||||
prompt_meta = await build_generation_record_prompt_meta(
|
|
||||||
db,
|
|
||||||
record_id=record.id,
|
|
||||||
attempt_no=prompt_attempt_no,
|
|
||||||
charge_kind=CHARGE_TEXT_PROMPT,
|
|
||||||
usage=token_usage,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
|
||||||
await deduct_credits(
|
|
||||||
db, current_user.id, text_credits,
|
|
||||||
f"提示词优化 - {project.name}",
|
|
||||||
related_id=record.id,
|
|
||||||
biz_key=prompt_biz_key,
|
|
||||||
record_meta=prompt_meta,
|
|
||||||
)
|
|
||||||
except InsufficientCreditsError as e:
|
|
||||||
# /optimize 阶段只处理提示词优化扣费。
|
|
||||||
# 提示词积分不足时,之前已落库的 optimizing 记录必须改为 failed,避免前端长期显示生成中。
|
|
||||||
# 此阶段没有媒体生成扣费,不调用生成失败退款逻辑。
|
|
||||||
await db.rollback()
|
|
||||||
result = await db.execute(
|
|
||||||
select(GenerationRecord)
|
|
||||||
.where(
|
|
||||||
GenerationRecord.id == failed_record_id,
|
|
||||||
GenerationRecord.user_id == failed_user_id,
|
|
||||||
GenerationRecord.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
failed_record = result.scalar_one_or_none()
|
|
||||||
if failed_record:
|
|
||||||
failed_record.status = "failed"
|
|
||||||
failed_record.error_message = e.detail
|
|
||||||
failed_record.optimized_prompt = None
|
|
||||||
failed_record.text_credits_cost = 0
|
|
||||||
failed_record.credits_cost = 0
|
|
||||||
failed_record.text_tokens_used = token_usage.get("total_tokens", 0)
|
|
||||||
await db.flush()
|
|
||||||
# 这里必须主动提交,否则后续抛出 402 后 get_db 会 rollback,failed 状态会被回滚。
|
|
||||||
await db.commit()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
record.optimized_prompt = optimized
|
|
||||||
record.status = "prompt_optimized"
|
|
||||||
record.text_credits_cost = round(text_credits, 2)
|
|
||||||
record.text_tokens_used = token_usage["total_tokens"]
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
|
||||||
return OptimizeResult(
|
|
||||||
optimized_prompt=optimized,
|
|
||||||
text_credits_cost=round(text_credits, 2),
|
|
||||||
# text_tokens_used=token_usage["total_tokens"],
|
|
||||||
record=_record_to_out(record, project.name, refs_override=refs),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{record_id}/generate")
|
@router.post("/{record_id}/generate")
|
||||||
async def generate(
|
async def generate_record_resource(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
req: GenerateParams,
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
user_id_snapshot = str(current_user.id)
|
||||||
|
try:
|
||||||
|
result = await execute_with_lock_timeout(
|
||||||
|
db,
|
||||||
select(GenerationRecord, Project.name)
|
select(GenerationRecord, Project.name)
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
GenerationRecord.id == record_id,
|
GenerationRecord.id == record_id,
|
||||||
GenerationRecord.user_id == current_user.id,
|
GenerationRecord.user_id == user_id_snapshot,
|
||||||
GenerationRecord.deleted_at.is_(None),
|
GenerationRecord.deleted_at.is_(None),
|
||||||
Project.deleted_at.is_(None),
|
Project.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.with_for_update()
|
.with_for_update(),
|
||||||
)
|
)
|
||||||
|
except DatabaseRowLockBusy as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||||
row = result.first()
|
row = result.first()
|
||||||
if not row:
|
if not row:
|
||||||
raise RecordNotFoundError()
|
raise RecordNotFoundError()
|
||||||
|
|
||||||
record, project_name = row
|
record, project_name = row
|
||||||
if record.status not in ("prompt_optimized", "failed"):
|
if record.status != "prompt_optimized":
|
||||||
raise InvalidStatusError("当前状态不允许生成")
|
raise InvalidStatusError("只有提词已完成的记录可以发起资源生成")
|
||||||
|
|
||||||
await assert_user_resource_capacity_available(db, current_user.id)
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_START,
|
||||||
|
event_status=LogEventStatusEnum.STARTED,
|
||||||
|
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
|
record=record,
|
||||||
|
detail={
|
||||||
|
"record_id": record.id,
|
||||||
|
"project_id": record.project_id,
|
||||||
|
"gen_type": record.gen_type,
|
||||||
|
"config_complete_before": _record_config_complete(record),
|
||||||
|
"config_recoverable": is_generation_record_config_recoverable(record),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ensure_generation_record_config_frozen(
|
||||||
|
db,
|
||||||
|
record,
|
||||||
|
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
|
)
|
||||||
|
if not _record_config_complete(record):
|
||||||
|
raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词")
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_CONFIG_READY,
|
||||||
|
event_status=LogEventStatusEnum.SUCCESS,
|
||||||
|
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
|
record=record,
|
||||||
|
detail={
|
||||||
|
"record_id": record.id,
|
||||||
|
"project_id": record.project_id,
|
||||||
|
"gen_type": record.gen_type,
|
||||||
|
"engine_id": record.engine_id,
|
||||||
|
"duration": record.duration,
|
||||||
|
"aspect_ratio": record.aspect_ratio,
|
||||||
|
"resolution": record.resolution,
|
||||||
|
"provider_generation_resolution": record.provider_generation_resolution,
|
||||||
|
"image_size": record.image_size,
|
||||||
|
"image_proportion": record.image_proportion,
|
||||||
|
"image_px": record.image_px,
|
||||||
|
"include_media_references": bool(record.include_media_references),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await assert_user_resource_capacity_available(db, user_id_snapshot)
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
owner_id=record.id,
|
owner_id=record.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if record.gen_type == GenerationType.video:
|
# Confirm that the bound engine still exists and is active, but never rebuild
|
||||||
# Video generation
|
# the snapshot or replace the user's frozen parameters with current defaults.
|
||||||
if req.aspect_ratio not in ASPECT_RATIOS:
|
if record.gen_type == GenerationType.video.value:
|
||||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
await get_video_engine(db, record.engine_id)
|
||||||
if req.resolution not in RESOLUTIONS:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
await get_image_engine(db, record.engine_id)
|
||||||
|
frozen_engine = _frozen_engine_view(record)
|
||||||
duration = record.duration or 5
|
reference_usage = calculate_media_reference_usage(
|
||||||
from app.services.video_gen import get_active_engine, submit_video_task
|
record.media_references,
|
||||||
engine = await get_active_engine(db)
|
include=bool(record.include_media_references),
|
||||||
engine_snapshot = _build_video_snapshot(engine, req.aspect_ratio, req.resolution, duration)
|
)
|
||||||
media_billing = await charge_generation_media_by_params(
|
validate_media_reference_usage_for_engine(
|
||||||
db,
|
reference_usage,
|
||||||
user_id=current_user.id,
|
gen_type=record.gen_type,
|
||||||
record_id=record.id,
|
engine=frozen_engine,
|
||||||
gen_type="video",
|
|
||||||
duration=duration,
|
|
||||||
resolution=req.resolution,
|
|
||||||
aspect_ratio=req.aspect_ratio,
|
|
||||||
fps=24,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
|
||||||
description_prefix=project_name+"-",
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.aspect_ratio = req.aspect_ratio
|
billing = await charge_generation_media_for_record(
|
||||||
record.resolution = req.resolution
|
db,
|
||||||
record.engine_id = engine.id
|
record=record,
|
||||||
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
|
project_name=project_name,
|
||||||
record.current_billing_attempt_no = attempt_no
|
description_prefix=project_name + "-",
|
||||||
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
attempt_no=attempt_no,
|
||||||
record.status = "generating"
|
engine_id=record.engine_id,
|
||||||
record.error_message = None
|
)
|
||||||
record.video_url = None
|
record.credits_cost = round(
|
||||||
record.video_cover_url = None
|
float(record.credits_cost or 0) + float(billing.total_charged or 0),
|
||||||
record.image_url = None
|
2,
|
||||||
record.seedance_task_id = None
|
)
|
||||||
await db.flush()
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_BILLING_SUCCESS,
|
||||||
|
event_status=LogEventStatusEnum.SUCCESS,
|
||||||
|
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
|
record=record,
|
||||||
|
detail={
|
||||||
|
"record_id": record.id,
|
||||||
|
"attempt_no": attempt_no,
|
||||||
|
"engine_id": record.engine_id,
|
||||||
|
"charged": float(billing.total_charged or 0),
|
||||||
|
"credits_cost_total": record.credits_cost,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
from app.services.generation.pipeline.generation_record_service import (
|
||||||
from app.services.error_codes import extract_error_message
|
commit_and_enqueue_generation_record,
|
||||||
from app.services.video_queue import task_queue
|
prepare_generation_record_execution,
|
||||||
|
)
|
||||||
|
|
||||||
task_id = await submit_video_task(
|
prepare_generation_record_execution(record, attempt_no=attempt_no)
|
||||||
|
await db.flush()
|
||||||
|
record_id_snapshot = str(record.id)
|
||||||
|
enqueue_log_record = GenerationRecord(
|
||||||
|
id=record_id_snapshot,
|
||||||
|
user_id=user_id_snapshot,
|
||||||
|
project_id=str(record.project_id),
|
||||||
|
original_prompt=record.original_prompt or "",
|
||||||
|
gen_type=record.gen_type,
|
||||||
|
duration=record.duration,
|
||||||
|
aspect_ratio=record.aspect_ratio,
|
||||||
|
resolution=record.resolution,
|
||||||
|
image_size=record.image_size,
|
||||||
|
image_proportion=record.image_proportion,
|
||||||
|
image_px=record.image_px,
|
||||||
|
engine_id=record.engine_id,
|
||||||
|
include_media_references=bool(record.include_media_references),
|
||||||
|
media_references=record.media_references,
|
||||||
|
)
|
||||||
|
enqueue_log_detail = {
|
||||||
|
"record_id": record_id_snapshot,
|
||||||
|
"attempt_no": attempt_no,
|
||||||
|
"reason": "generation_record_api_generate",
|
||||||
|
}
|
||||||
|
await commit_and_enqueue_generation_record(
|
||||||
db,
|
db,
|
||||||
engine,
|
|
||||||
record,
|
record,
|
||||||
include_media_references=False,
|
reason="generation_record_api_generate",
|
||||||
)
|
)
|
||||||
record.seedance_task_id = task_id
|
log_generation_record_config_event(
|
||||||
await db.flush()
|
event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_ENQUEUE_SUCCESS,
|
||||||
await task_queue.enqueue(record_id)
|
event_status=LogEventStatusEnum.SUCCESS,
|
||||||
except Exception as e:
|
source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
await mark_generation_record_failed_and_refund_once(
|
record=enqueue_log_record,
|
||||||
|
detail=enqueue_log_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed = await db.execute(
|
||||||
|
select(GenerationRecord, Project.name)
|
||||||
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
|
.where(GenerationRecord.id == record_id_snapshot)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
refreshed_row = refreshed.first()
|
||||||
|
if not refreshed_row:
|
||||||
|
raise RecordNotFoundError()
|
||||||
|
record, project_name = refreshed_row
|
||||||
|
refs = await resolve_private_portrait_reference_display_urls(
|
||||||
db,
|
db,
|
||||||
record=record,
|
json.loads(record.media_references) if record.media_references else None,
|
||||||
error_message=extract_error_message(e, "视频"),
|
user_id=user_id_snapshot,
|
||||||
)
|
)
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
elif record.gen_type == GenerationType.image:
|
|
||||||
image_size = req.image_size or record.image_size or "2K"
|
|
||||||
from app.services.image_gen import get_active_image_engine
|
|
||||||
engine = await get_active_image_engine(db)
|
|
||||||
image_proportion = record.image_proportion or "1:1"
|
|
||||||
image_px = record.image_px or "2048x2048"
|
|
||||||
engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px)
|
|
||||||
media_billing = await charge_generation_media_by_params(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
record_id=record.id,
|
|
||||||
gen_type="image",
|
|
||||||
image_size=image_size,
|
|
||||||
image_px=image_px,
|
|
||||||
aspect_ratio=image_proportion,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
|
||||||
description_prefix=project_name+"-",
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
|
||||||
|
|
||||||
record.image_size = image_size
|
|
||||||
record.engine_id = engine.id
|
|
||||||
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
|
|
||||||
record.current_billing_attempt_no = attempt_no
|
|
||||||
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
||||||
record.status = "generating"
|
|
||||||
record.error_message = None
|
|
||||||
record.image_url = None
|
|
||||||
record.video_url = None
|
|
||||||
record.video_cover_url = None
|
|
||||||
record.seedance_task_id = None
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.video_queue import task_queue
|
|
||||||
await task_queue.enqueue(record_id)
|
|
||||||
except Exception as e:
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
|
||||||
record=record,
|
|
||||||
error_message=f"图片任务队列投递失败: {e}",
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
|
||||||
return _record_to_out(record, project_name, refs_override=refs)
|
return _record_to_out(record, project_name, refs_override=refs)
|
||||||
|
|
||||||
|
|
||||||
@@ -545,105 +507,100 @@ async def retry_generation(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
user_id_snapshot = str(current_user.id)
|
||||||
|
try:
|
||||||
|
result = await execute_with_lock_timeout(
|
||||||
|
db,
|
||||||
select(GenerationRecord, Project.name)
|
select(GenerationRecord, Project.name)
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
GenerationRecord.id == record_id,
|
GenerationRecord.id == record_id,
|
||||||
GenerationRecord.user_id == current_user.id,
|
GenerationRecord.user_id == user_id_snapshot,
|
||||||
GenerationRecord.deleted_at.is_(None),
|
GenerationRecord.deleted_at.is_(None),
|
||||||
Project.deleted_at.is_(None),
|
Project.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.with_for_update()
|
.with_for_update(),
|
||||||
)
|
)
|
||||||
|
except DatabaseRowLockBusy as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||||
row = result.first()
|
row = result.first()
|
||||||
if not row:
|
if not row:
|
||||||
raise RecordNotFoundError()
|
raise RecordNotFoundError()
|
||||||
|
|
||||||
record, project_name = row
|
record, project_name = row
|
||||||
if record.status != "failed":
|
if record.status != "failed":
|
||||||
raise InvalidStatusError("只有失败的记录可以重试")
|
raise InvalidStatusError("只有失败的记录可以重试")
|
||||||
|
if record.resource_generation_started_at is None:
|
||||||
|
raise InvalidStatusError("该记录失败于提词阶段,请重新生成提词")
|
||||||
|
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
|
||||||
|
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||||
|
if not _record_config_complete(record):
|
||||||
|
raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词")
|
||||||
|
|
||||||
await assert_user_resource_capacity_available(db, current_user.id)
|
await assert_user_resource_capacity_available(db, user_id_snapshot)
|
||||||
|
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
owner_id=record.id,
|
owner_id=record.id,
|
||||||
)
|
)
|
||||||
if record.gen_type == GenerationType.video:
|
if record.gen_type == GenerationType.video.value:
|
||||||
from app.services.video_gen import get_active_engine
|
await get_video_engine(db, record.engine_id)
|
||||||
engine = await get_active_engine(db)
|
|
||||||
engine_snapshot = _build_video_snapshot(
|
|
||||||
engine,
|
|
||||||
record.aspect_ratio or "16:9",
|
|
||||||
record.resolution or "720p",
|
|
||||||
record.duration or 5,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
from app.services.image_gen import get_active_image_engine
|
await get_image_engine(db, record.engine_id)
|
||||||
engine = await get_active_image_engine(db)
|
frozen_engine = _frozen_engine_view(record)
|
||||||
engine_snapshot = _build_image_snapshot(
|
reference_usage = calculate_media_reference_usage(
|
||||||
engine,
|
record.media_references,
|
||||||
record.image_size or "2K",
|
include=bool(record.include_media_references),
|
||||||
record.image_proportion or "1:1",
|
|
||||||
record.image_px or "2048x2048",
|
|
||||||
)
|
)
|
||||||
media_billing = await charge_generation_media_by_params(
|
validate_media_reference_usage_for_engine(
|
||||||
db,
|
reference_usage,
|
||||||
user_id=record.user_id,
|
|
||||||
record_id=record.id,
|
|
||||||
gen_type=record.gen_type,
|
gen_type=record.gen_type,
|
||||||
image_size=record.image_size,
|
engine=frozen_engine,
|
||||||
image_px=record.image_px,
|
|
||||||
aspect_ratio=record.aspect_ratio or record.image_proportion,
|
|
||||||
duration=record.duration,
|
|
||||||
resolution=record.resolution,
|
|
||||||
fps=24 if record.gen_type == GenerationType.video else None,
|
|
||||||
engine_id=engine.id,
|
|
||||||
project_name=project_name,
|
|
||||||
description_prefix="生成重试-",
|
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
attempt_no=attempt_no,
|
|
||||||
media_references=record.media_references,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
record.engine_id = engine.id
|
billing = await charge_generation_media_for_record(
|
||||||
record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str)
|
|
||||||
record.current_billing_attempt_no = attempt_no
|
|
||||||
record.status = "generating"
|
|
||||||
record.error_message = None
|
|
||||||
record.video_url = None
|
|
||||||
record.video_cover_url = None
|
|
||||||
record.image_url = None
|
|
||||||
record.seedance_task_id = None
|
|
||||||
record.generated_at = None
|
|
||||||
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.video_queue import task_queue
|
|
||||||
if record.gen_type == GenerationType.video:
|
|
||||||
from app.services.video_gen import submit_video_task, extract_error_message
|
|
||||||
task_id = await submit_video_task(
|
|
||||||
db,
|
|
||||||
engine,
|
|
||||||
record,
|
|
||||||
include_media_references=False,
|
|
||||||
)
|
|
||||||
record.seedance_task_id = task_id
|
|
||||||
await db.flush()
|
|
||||||
await task_queue.enqueue(record_id)
|
|
||||||
except Exception as e:
|
|
||||||
from app.services.error_codes import extract_error_message
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
db,
|
||||||
record=record,
|
record=record,
|
||||||
error_message=extract_error_message(e, "重试"),
|
project_name=project_name,
|
||||||
|
description_prefix="资源生成重试-",
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
engine_id=record.engine_id,
|
||||||
)
|
)
|
||||||
await db.flush()
|
record.credits_cost = round(
|
||||||
|
float(record.credits_cost or 0) + float(billing.total_charged or 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
record.manual_retry_count = int(record.manual_retry_count or 0) + 1
|
||||||
|
record.retry_count = int(record.manual_retry_count or 0)
|
||||||
|
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
from app.services.generation.pipeline.generation_record_service import (
|
||||||
|
commit_and_enqueue_generation_record,
|
||||||
|
prepare_generation_record_execution,
|
||||||
|
)
|
||||||
|
|
||||||
|
prepare_generation_record_execution(record, attempt_no=attempt_no)
|
||||||
|
await db.flush()
|
||||||
|
record_id_snapshot = str(record.id)
|
||||||
|
await commit_and_enqueue_generation_record(
|
||||||
|
db,
|
||||||
|
record,
|
||||||
|
reason="generation_record_api_retry",
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed = await db.execute(
|
||||||
|
select(GenerationRecord, Project.name)
|
||||||
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
|
.where(GenerationRecord.id == record_id_snapshot)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
refreshed_row = refreshed.first()
|
||||||
|
if not refreshed_row:
|
||||||
|
raise RecordNotFoundError()
|
||||||
|
record, project_name = refreshed_row
|
||||||
|
refs = await resolve_private_portrait_reference_display_urls(
|
||||||
|
db,
|
||||||
|
json.loads(record.media_references) if record.media_references else None,
|
||||||
|
user_id=user_id_snapshot,
|
||||||
|
)
|
||||||
return _record_to_out(record, project_name, refs_override=refs)
|
return _record_to_out(record, project_name, refs_override=refs)
|
||||||
|
|
||||||
|
|
||||||
@@ -716,11 +673,15 @@ async def get_queue_status(
|
|||||||
estimated_wait_seconds = None
|
estimated_wait_seconds = None
|
||||||
|
|
||||||
if record.status == "generating":
|
if record.status == "generating":
|
||||||
|
resource_started_at = record.resource_generation_started_at or record.created_at
|
||||||
ahead_result = await db.execute(
|
ahead_result = await db.execute(
|
||||||
select(func.count(GenerationRecord.id)).where(
|
select(func.count(GenerationRecord.id)).where(
|
||||||
GenerationRecord.status == "generating",
|
GenerationRecord.status == "generating",
|
||||||
GenerationRecord.deleted_at.is_(None),
|
GenerationRecord.deleted_at.is_(None),
|
||||||
GenerationRecord.created_at < record.created_at,
|
func.coalesce(
|
||||||
|
GenerationRecord.resource_generation_started_at,
|
||||||
|
GenerationRecord.created_at,
|
||||||
|
) < resource_started_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ahead = ahead_result.scalar() or 0
|
ahead = ahead_result.scalar() or 0
|
||||||
@@ -730,112 +691,13 @@ async def get_queue_status(
|
|||||||
return {
|
return {
|
||||||
"record_id": record.id,
|
"record_id": record.id,
|
||||||
"status": record.status,
|
"status": record.status,
|
||||||
|
"pipeline_stage": record.pipeline_stage,
|
||||||
|
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
|
||||||
"queue_position": queue_position,
|
"queue_position": queue_position,
|
||||||
"estimated_wait_seconds": estimated_wait_seconds,
|
"estimated_wait_seconds": estimated_wait_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/callbacks/seedance")
|
|
||||||
async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Receive async callback from Seedance API."""
|
|
||||||
data = await request.json()
|
|
||||||
task_id = data.get("id")
|
|
||||||
task_status = data.get("status")
|
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
return {"message": "ignored"}
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(GenerationRecord).where(
|
|
||||||
GenerationRecord.seedance_task_id == task_id,
|
|
||||||
GenerationRecord.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
record = result.scalar_one_or_none()
|
|
||||||
if not record:
|
|
||||||
return {"message": "record not found"}
|
|
||||||
|
|
||||||
if task_status == "succeeded":
|
|
||||||
remote_url = data.get("content", {}).get("video_url", "")
|
|
||||||
record.status = "completed"
|
|
||||||
storage_path = None
|
|
||||||
file_size_bytes = 0
|
|
||||||
# Download video to local storage
|
|
||||||
if settings.STORAGE_TYPE == "local" and remote_url:
|
|
||||||
try:
|
|
||||||
from app.services.video_gen import download_video
|
|
||||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
|
||||||
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
|
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
|
||||||
dest = os.path.join(dest_dir, f"{record.id}.mp4")
|
|
||||||
await download_video(remote_url, dest)
|
|
||||||
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
|
|
||||||
cover_url, _cover_storage_path = await async_create_video_cover_for_local_video(
|
|
||||||
record_id=record.id,
|
|
||||||
video_path=dest,
|
|
||||||
date_dir=date_dir,
|
|
||||||
log_prefix=f"SeedanceCallback视频封面生成 record_id={record.id}",
|
|
||||||
)
|
|
||||||
record.video_cover_url = cover_url
|
|
||||||
storage_path = dest
|
|
||||||
file_size_bytes = safe_file_size(dest)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Callback download failed, using remote URL: {e}")
|
|
||||||
record.video_url = remote_url
|
|
||||||
else:
|
|
||||||
record.video_url = remote_url
|
|
||||||
record.generated_at = datetime.now()
|
|
||||||
if record.video_url:
|
|
||||||
await record_generation_record_generated_resource(
|
|
||||||
db,
|
|
||||||
record,
|
|
||||||
resource_url=record.video_url,
|
|
||||||
storage_path=storage_path,
|
|
||||||
file_size_bytes=file_size_bytes,
|
|
||||||
remote_url=remote_url,
|
|
||||||
generated_at=record.generated_at,
|
|
||||||
)
|
|
||||||
# Extract video token usage from callback
|
|
||||||
usage = data.get("usage", {})
|
|
||||||
if usage:
|
|
||||||
record.video_tokens_used = usage.get("total_tokens", 0)
|
|
||||||
await sync_generation_record_media_token_snapshot(db, record, provider_response=data)
|
|
||||||
# Log callback response
|
|
||||||
from app.services.video_gen import _log_video_response
|
|
||||||
_log_video_response(record.id, data)
|
|
||||||
# Notify user
|
|
||||||
from app.services.notification import create_notification
|
|
||||||
from app.api.v1.notifications import push_notification_to_user
|
|
||||||
notif = await create_notification(
|
|
||||||
db, record.user_id, "视频生成完成",
|
|
||||||
"您的视频已生成完成,可以查看了。", "video", record.id,
|
|
||||||
)
|
|
||||||
await push_notification_to_user(record.user_id, notif)
|
|
||||||
elif task_status == "failed":
|
|
||||||
error_message = data.get("error", "视频生成失败")
|
|
||||||
await mark_generation_record_failed_and_refund_once(
|
|
||||||
db,
|
|
||||||
record=record,
|
|
||||||
error_message=error_message,
|
|
||||||
)
|
|
||||||
# Log callback response
|
|
||||||
from app.services.video_gen import _log_video_response
|
|
||||||
_log_video_response(record.id, data, error=record.error_message)
|
|
||||||
# Notify user
|
|
||||||
from app.services.notification import create_notification
|
|
||||||
from app.api.v1.notifications import push_notification_to_user
|
|
||||||
notif = await create_notification(
|
|
||||||
db, record.user_id, "视频生成失败",
|
|
||||||
f"视频生成失败:{record.error_message}", "video", record.id,
|
|
||||||
)
|
|
||||||
await push_notification_to_user(record.user_id, notif)
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
return {"message": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/upload-image",
|
"/upload-image",
|
||||||
summary="上传 AI 创作普通参考图片",
|
summary="上传 AI 创作普通参考图片",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -19,25 +21,40 @@ from app.schemas.generation_ai import (
|
|||||||
GenerationAITaskListOut,
|
GenerationAITaskListOut,
|
||||||
GenerationAITaskOut,
|
GenerationAITaskOut,
|
||||||
)
|
)
|
||||||
from app.services.generation_ai_service import (
|
from app.services.generation.pipeline.db_lock_service import (
|
||||||
create_async_generation_task,
|
DatabaseRowLockBusy,
|
||||||
|
execute_with_lock_timeout,
|
||||||
|
)
|
||||||
|
from app.services.generation.ai.service import (
|
||||||
|
build_task_out_list,
|
||||||
list_generation_ai_engine_options,
|
list_generation_ai_engine_options,
|
||||||
list_async_generation_tasks,
|
list_async_generation_tasks,
|
||||||
list_generation_history_day_items,
|
list_generation_history_day_items,
|
||||||
list_generation_history_grouped_days,
|
list_generation_history_grouped_days,
|
||||||
record_to_out,
|
|
||||||
soft_delete_chat_generation_task,
|
|
||||||
)
|
)
|
||||||
from app.services.generation_billing_service import (
|
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus, GenerationMode
|
||||||
|
from app.services.generation.ai.task_create_service import (
|
||||||
|
GenerationTaskCreateResult,
|
||||||
|
create_generation_task_group,
|
||||||
|
enqueue_created_generation_tasks,
|
||||||
|
find_existing_top_level_task,
|
||||||
|
)
|
||||||
|
from app.services.generation.ai.task_group_service import (
|
||||||
|
aggregate_main_task_status,
|
||||||
|
load_children_map,
|
||||||
|
soft_delete_child_task,
|
||||||
|
soft_delete_top_level_task_group,
|
||||||
|
)
|
||||||
|
from app.services.generation.billing_service import (
|
||||||
OWNER_CHAT_GENERATION_TASK,
|
OWNER_CHAT_GENERATION_TASK,
|
||||||
charge_generation_media_by_params,
|
charge_generation_media_by_params,
|
||||||
get_next_credit_attempt_no,
|
get_next_credit_attempt_no,
|
||||||
)
|
)
|
||||||
from app.services.generation_history_delete_service import batch_delete_generation_history_items
|
from app.services.generation.history_delete_service import batch_delete_generation_history_items
|
||||||
from app.services.generation_log_service import log_task_event
|
from app.services.generation.log_service import log_task_event
|
||||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
from app.services.generation.media_reference_service import calculate_media_reference_usage
|
||||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
|
||||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||||
|
from app.services.operation_log_service import log_operation_event
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -146,6 +163,7 @@ async def create_task(
|
|||||||
...,
|
...,
|
||||||
description=(
|
description=(
|
||||||
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
|
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
|
||||||
|
"generation_count 为客户端本次选择的生成数量,默认1,后端会按引擎开关和数量上限校验。"
|
||||||
"枚举:gen_type=image/video;media_references[].type=image/video/audio;"
|
"枚举:gen_type=image/video;media_references[].type=image/video/audio;"
|
||||||
"media_references[].source=upload_resource/private_portrait_asset/空;"
|
"media_references[].source=upload_resource/private_portrait_asset/空;"
|
||||||
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
|
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
|
||||||
@@ -157,34 +175,88 @@ async def create_task(
|
|||||||
if celery_app is None:
|
if celery_app is None:
|
||||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||||
|
|
||||||
task = await create_async_generation_task(db, current_user, req)
|
try:
|
||||||
|
create_result = await create_generation_task_group(db, current_user, req)
|
||||||
|
top_level_task_id = str(create_result.top_level_task_id)
|
||||||
|
enqueue_task_ids = list(create_result.enqueue_task_ids)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# 并发重复请求可能同时通过预查询;唯一索引负责兜底。
|
||||||
|
# 回滚本次任务和计费后,按幂等键返回已经成功提交的顶层任务。
|
||||||
|
await db.rollback()
|
||||||
|
existing = await find_existing_top_level_task(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
idempotency_key=req.idempotency_key,
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise
|
||||||
|
create_result = GenerationTaskCreateResult(
|
||||||
|
top_level_task_id=str(existing.id),
|
||||||
|
generation_count=int(existing.generation_count or 1),
|
||||||
|
gen_type=str(existing.gen_type),
|
||||||
|
created=False,
|
||||||
|
)
|
||||||
|
top_level_task_id = str(existing.id)
|
||||||
|
enqueue_task_ids = []
|
||||||
|
|
||||||
|
if create_result.created:
|
||||||
|
log_operation_event(
|
||||||
|
domain="generation_ai_batch",
|
||||||
|
event_type="BATCH_COMMIT_SUCCESS",
|
||||||
|
event_status="success",
|
||||||
|
source="api",
|
||||||
|
user_id=current_user.id,
|
||||||
|
group_id=top_level_task_id,
|
||||||
|
task_id=top_level_task_id,
|
||||||
|
detail={
|
||||||
|
"gen_type": create_result.gen_type,
|
||||||
|
"generation_count": create_result.generation_count,
|
||||||
|
"child_task_ids": create_result.child_task_ids,
|
||||||
|
"physical_files_deleted": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
task_id=top_level_task_id,
|
||||||
event_type="TASK_CREATED",
|
event_type=(
|
||||||
to_status="generating",
|
"TASK_CREATED" if create_result.created else "IDEMPOTENCY_HIT"
|
||||||
to_stage="queued",
|
),
|
||||||
detail={"gen_type": task.gen_type},
|
to_status="generating" if create_result.created else None,
|
||||||
|
to_stage="queued" if create_result.created else None,
|
||||||
|
detail={
|
||||||
|
"gen_type": create_result.gen_type,
|
||||||
|
"generation_count": create_result.generation_count,
|
||||||
|
"child_task_ids": create_result.child_task_ids,
|
||||||
|
"created": create_result.created,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
failed_enqueue_ids: list[str] = []
|
||||||
|
if create_result.created and enqueue_task_ids:
|
||||||
try:
|
failed_enqueue_ids = await enqueue_created_generation_tasks(
|
||||||
chatapi_create_generation_task.delay(task.id)
|
|
||||||
except Exception as exc:
|
|
||||||
await mark_chat_generation_task_failed_and_refund_once(
|
|
||||||
db,
|
db,
|
||||||
task_id=task.id,
|
task_ids=enqueue_task_ids,
|
||||||
error_message=f"任务队列投递失败: {exc}",
|
|
||||||
pipeline_stage="failed",
|
|
||||||
)
|
)
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
|
|
||||||
|
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
|
|
||||||
return record_to_out(task, media_references=refs)
|
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChatGenerationTask).where(
|
||||||
|
ChatGenerationTask.id == top_level_task_id,
|
||||||
|
ChatGenerationTask.user_id == current_user.id,
|
||||||
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务创建后未找到")
|
||||||
|
output = await build_task_out_list(
|
||||||
|
db,
|
||||||
|
[task],
|
||||||
|
viewer_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
if failed_enqueue_ids and len(failed_enqueue_ids) == len(enqueue_task_ids):
|
||||||
|
raise HTTPException(status_code=503, detail="任务已创建,但任务队列投递失败,请稍后重试")
|
||||||
|
return output[0]
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/tasks",
|
"/tasks",
|
||||||
@@ -261,10 +333,8 @@ async def list_tasks(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
is_admin = False
|
is_admin = current_user.user_type == "admin"
|
||||||
if current_user.user_type == 'admin':
|
if not is_admin:
|
||||||
is_admin = True
|
|
||||||
else:
|
|
||||||
user_id = current_user.id
|
user_id = current_user.id
|
||||||
|
|
||||||
total, items = await list_async_generation_tasks(
|
total, items = await list_async_generation_tasks(
|
||||||
@@ -280,24 +350,17 @@ async def list_tasks(
|
|||||||
created_start=created_start,
|
created_start=created_start,
|
||||||
created_end=created_end,
|
created_end=created_end,
|
||||||
)
|
)
|
||||||
|
# 同一个 API 同时服务管理后台和客户端:
|
||||||
# ====================== 在这里加排序(最新在前)======================
|
# - 管理员保持数据库倒序,最新记录在列表上方;
|
||||||
if not is_admin:
|
# - 普通用户先查询最新一页,再仅反转当前页,聊天消息从旧到新排列。
|
||||||
# 按 created_at 降序(没有则用 id 降序)
|
items_for_output = items if is_admin else list(reversed(items))
|
||||||
items_sorted = sorted(
|
out_items = await build_task_out_list(
|
||||||
items,
|
|
||||||
key=lambda x: x.created_at if x.created_at is not None else x.id,
|
|
||||||
reverse=False # 升序
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
items_sorted = items
|
|
||||||
refs_map = await batch_resolve_private_portrait_reference_display_urls(
|
|
||||||
db,
|
db,
|
||||||
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted},
|
items_for_output,
|
||||||
user_id=None if is_admin else current_user.id,
|
is_admin=is_admin,
|
||||||
|
viewer_user_id=None if is_admin else current_user.id,
|
||||||
)
|
)
|
||||||
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted])
|
return GenerationAITaskListOut(total=total, items=out_items)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/history",
|
"/history",
|
||||||
@@ -513,8 +576,9 @@ async def list_history_day_items(
|
|||||||
summary="获取AI生成任务详情",
|
summary="获取AI生成任务详情",
|
||||||
description=(
|
description=(
|
||||||
"根据任务ID获取当前登录用户的AI生成任务详情。"
|
"根据任务ID获取当前登录用户的AI生成任务详情。"
|
||||||
"只能查询当前用户自己的任务,且只查询 generation_mode=chatapi_async 的任务。"
|
"支持 chatapi_async、chatapi_main 和未删除的 chatapi_child。"
|
||||||
"如果任务不存在或不属于当前用户,返回404。"
|
"查询 chatapi_main 时返回按 generation_index 升序排列的 child_items。"
|
||||||
|
"已软删除 child 只在父任务 child_items 中保留槽位,不能通过 child ID 单独查询。"
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -541,17 +605,19 @@ async def get_task(
|
|||||||
select(ChatGenerationTask).where(
|
select(ChatGenerationTask).where(
|
||||||
ChatGenerationTask.id == task_id,
|
ChatGenerationTask.id == task_id,
|
||||||
ChatGenerationTask.user_id == current_user.id,
|
ChatGenerationTask.user_id == current_user.id,
|
||||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
).limit(1)
|
||||||
ChatGenerationTask.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
task = result.scalar_one_or_none()
|
task = result.scalar_one_or_none()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
|
if task.deleted_at is not None:
|
||||||
return record_to_out(task, media_references=refs)
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
output = await build_task_out_list(
|
||||||
|
db,
|
||||||
|
[task],
|
||||||
|
viewer_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
return output[0]
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/tasks/{task_id}",
|
"/tasks/{task_id}",
|
||||||
@@ -587,38 +653,33 @@ async def delete_task(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
mode_result = await db.execute(
|
||||||
select(ChatGenerationTask).where(
|
select(ChatGenerationTask.generation_mode).where(
|
||||||
ChatGenerationTask.id == task_id,
|
ChatGenerationTask.id == task_id,
|
||||||
ChatGenerationTask.user_id == current_user.id,
|
ChatGenerationTask.user_id == current_user.id,
|
||||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
).limit(1)
|
||||||
ChatGenerationTask.deleted_at.is_(None),
|
|
||||||
)
|
)
|
||||||
.limit(1)
|
generation_mode = mode_result.scalar_one_or_none()
|
||||||
)
|
if generation_mode == GenerationMode.CHATAPI_CHILD.value:
|
||||||
task = result.scalar_one_or_none()
|
freed_size_bytes = await soft_delete_child_task(
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
|
||||||
|
|
||||||
if task.status == "generating":
|
|
||||||
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除")
|
|
||||||
|
|
||||||
deleted_at = datetime.now(timezone.utc)
|
|
||||||
freed_size_bytes = await soft_delete_chat_generation_task(
|
|
||||||
db,
|
db,
|
||||||
task=task,
|
child_task_id=task_id,
|
||||||
deleted_at=deleted_at,
|
user_id=current_user.id,
|
||||||
)
|
)
|
||||||
await db.flush()
|
else:
|
||||||
|
freed_size_bytes = await soft_delete_top_level_task_group(
|
||||||
|
db,
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
return GenerationAITaskDeleteOut(
|
return GenerationAITaskDeleteOut(
|
||||||
message="任务已删除",
|
message="任务已删除",
|
||||||
task_id=task.id,
|
task_id=task_id,
|
||||||
deleted=True,
|
deleted=True,
|
||||||
freed_size_bytes=freed_size_bytes,
|
freed_size_bytes=freed_size_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/tasks/{task_id}/retry",
|
"/tasks/{task_id}/retry",
|
||||||
response_model=GenerationAIRetryOut,
|
response_model=GenerationAIRetryOut,
|
||||||
@@ -660,83 +721,205 @@ async def retry_task(
|
|||||||
if celery_app is None:
|
if celery_app is None:
|
||||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||||
|
|
||||||
result = await db.execute(
|
try:
|
||||||
|
result = await execute_with_lock_timeout(
|
||||||
|
db,
|
||||||
select(ChatGenerationTask).where(
|
select(ChatGenerationTask).where(
|
||||||
ChatGenerationTask.id == task_id,
|
ChatGenerationTask.id == task_id,
|
||||||
ChatGenerationTask.user_id == current_user.id,
|
ChatGenerationTask.user_id == current_user.id,
|
||||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
|
||||||
ChatGenerationTask.deleted_at.is_(None),
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
|
).with_for_update().limit(1),
|
||||||
)
|
)
|
||||||
.with_for_update()
|
except DatabaseRowLockBusy as exc:
|
||||||
.limit(1)
|
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||||
)
|
|
||||||
task = result.scalar_one_or_none()
|
task = result.scalar_one_or_none()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
if task.status != "failed":
|
|
||||||
|
retry_targets: list[ChatGenerationTask]
|
||||||
|
retrying_group_children = False
|
||||||
|
if task.generation_mode == GenerationMode.CHATAPI_MAIN.value:
|
||||||
|
children_map = await load_children_map(db, [task.id], include_deleted=False)
|
||||||
|
children = children_map.get(task.id, [])
|
||||||
|
if task.gen_type == "video":
|
||||||
|
retry_targets = [
|
||||||
|
child for child in children
|
||||||
|
if child.status == ChatGenerationTaskStatus.FAILED.value
|
||||||
|
]
|
||||||
|
retrying_group_children = True
|
||||||
|
if not retry_targets:
|
||||||
|
raise HTTPException(status_code=400, detail="当前视频任务组没有可重试的失败子任务")
|
||||||
|
elif children:
|
||||||
|
# 图片供应商全部成功后才会拆子任务;已有子任务时只允许重试下载,
|
||||||
|
# 不能再次扣费并覆盖原有生成序号。
|
||||||
|
retry_targets = [
|
||||||
|
child for child in children
|
||||||
|
if child.status == ChatGenerationTaskStatus.FAILED.value
|
||||||
|
and child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
|
||||||
|
and bool(child.remote_result_url)
|
||||||
|
]
|
||||||
|
retrying_group_children = True
|
||||||
|
if not retry_targets:
|
||||||
|
raise HTTPException(status_code=400, detail="当前图片任务组没有可重试的下载失败子任务")
|
||||||
|
else:
|
||||||
|
# 图片批次在供应商阶段整批失败时尚未创建子任务,可整批重新生成并重新计费。
|
||||||
|
if task.status != ChatGenerationTaskStatus.FAILED.value:
|
||||||
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
||||||
|
retry_targets = [task]
|
||||||
|
else:
|
||||||
|
if task.status != ChatGenerationTaskStatus.FAILED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
||||||
|
retry_targets = [task]
|
||||||
|
|
||||||
|
upscale_failed_ids = [
|
||||||
|
str(target.id)
|
||||||
|
for target in retry_targets
|
||||||
|
if target.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value
|
||||||
|
]
|
||||||
|
if upscale_failed_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "画质增强失败任务不能通过普通生成重试,请由管理员使用视频超分恢复命令处理",
|
||||||
|
"task_ids": upscale_failed_ids,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await assert_user_resource_capacity_available(db, current_user.id)
|
await assert_user_resource_capacity_available(db, current_user.id)
|
||||||
|
enqueue_ids: list[str] = []
|
||||||
|
download_retry_ids: list[str] = []
|
||||||
|
for target in retry_targets:
|
||||||
|
if int(target.manual_retry_count or 0) >= 3:
|
||||||
|
raise HTTPException(status_code=400, detail=f"任务 {target.id} 已超过最大重试次数")
|
||||||
|
|
||||||
|
is_download_retry = bool(
|
||||||
|
target.remote_result_url
|
||||||
|
and target.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
|
||||||
|
)
|
||||||
|
if not is_download_retry:
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
owner_id=task.id,
|
owner_id=target.id,
|
||||||
)
|
)
|
||||||
|
quantity = int(target.generation_count or 1) if (
|
||||||
|
target.generation_mode == GenerationMode.CHATAPI_MAIN.value and target.gen_type == "image"
|
||||||
|
) else 1
|
||||||
|
refs = target.media_references or "[]"
|
||||||
|
if isinstance(refs, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
refs = json.loads(refs)
|
||||||
|
except Exception:
|
||||||
|
refs = []
|
||||||
|
reference_usage = calculate_media_reference_usage(refs, include=True)
|
||||||
media_billing = await charge_generation_media_by_params(
|
media_billing = await charge_generation_media_by_params(
|
||||||
db,
|
db,
|
||||||
user_id=task.user_id,
|
user_id=target.user_id,
|
||||||
record_id=task.id,
|
record_id=target.id,
|
||||||
gen_type=task.gen_type,
|
gen_type=target.gen_type,
|
||||||
image_size=task.image_size,
|
image_size=target.image_size,
|
||||||
image_px=task.image_px,
|
duration=target.duration,
|
||||||
aspect_ratio=task.aspect_ratio or task.image_proportion,
|
resolution=target.resolution,
|
||||||
duration=task.duration,
|
engine_id=target.engine_id,
|
||||||
resolution=task.resolution,
|
input_video_duration=reference_usage.input_video_duration or None,
|
||||||
fps=24 if task.gen_type == "video" else None,
|
input_image_count=reference_usage.image_count or None,
|
||||||
engine_id=task.engine_id,
|
|
||||||
project_name="AI生成任务",
|
project_name="AI生成任务",
|
||||||
description_prefix="Chat任务重试",
|
description_prefix="Chat任务重试",
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
media_references=task.media_references,
|
quantity=quantity,
|
||||||
)
|
)
|
||||||
|
target.credits_cost = round(float(target.credits_cost or 0) + media_billing.total_charged, 2)
|
||||||
|
resource_started_at = datetime.now(timezone.utc)
|
||||||
|
target.generation_attempt_no = int(attempt_no)
|
||||||
|
target.resource_generation_started_at = resource_started_at
|
||||||
|
if target.gen_type == "image":
|
||||||
|
target.deadline_at = resource_started_at + timedelta(
|
||||||
|
minutes=int(settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES or 30)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
target.deadline_at = resource_started_at + timedelta(
|
||||||
|
hours=int(settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS or 24)
|
||||||
|
)
|
||||||
|
target.provider_task_id = None
|
||||||
|
target.seedance_task_id = None
|
||||||
|
target.remote_result_url = None
|
||||||
|
target.provider_response_json = None
|
||||||
|
target.provider_create_claim_token = None
|
||||||
|
target.provider_create_lease_until = None
|
||||||
|
target.provider_create_started_at = None
|
||||||
|
target.poll_started_at = None
|
||||||
|
target.poll_claim_token = None
|
||||||
|
target.poll_lease_until = None
|
||||||
|
target.poll_error_count = 0
|
||||||
|
target.next_poll_at = None
|
||||||
|
target.poll_interval_seconds = 0
|
||||||
|
target.download_celery_task_id = None
|
||||||
|
target.download_enqueued_at = None
|
||||||
|
target.download_started_at = None
|
||||||
|
target.download_claim_token = None
|
||||||
|
target.download_lease_until = None
|
||||||
|
target.download_next_retry_at = None
|
||||||
|
target.download_attempt_count = 0
|
||||||
|
target.download_last_error = None
|
||||||
|
target.download_storage_date_dir = None
|
||||||
|
target.image_url = None
|
||||||
|
target.video_url = None
|
||||||
|
target.video_cover_url = None
|
||||||
|
target.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||||
|
enqueue_ids.append(str(target.id))
|
||||||
|
else:
|
||||||
|
target.pipeline_stage = ChatGenerationPipelineStage.RESULT_READY.value
|
||||||
|
download_retry_ids.append(str(target.id))
|
||||||
|
|
||||||
task.current_billing_attempt_no = attempt_no
|
target.status = ChatGenerationTaskStatus.GENERATING.value
|
||||||
task.status = "generating"
|
target.error_message = None
|
||||||
task.pipeline_stage = "queued"
|
target.poll_count = 0
|
||||||
task.error_message = None
|
target.last_poll_at = None
|
||||||
task.poll_count = 0
|
target.generated_at = None
|
||||||
task.last_poll_at = None
|
target.manual_retry_count = int(target.manual_retry_count or 0) + 1
|
||||||
task.provider_task_id = None
|
target.retry_count = int(target.manual_retry_count or 0)
|
||||||
task.seedance_task_id = None
|
|
||||||
task.remote_result_url = None
|
|
||||||
task.provider_response_json = None
|
|
||||||
task.image_url = None
|
|
||||||
task.video_url = None
|
|
||||||
task.video_cover_url = None
|
|
||||||
task.generated_at = None
|
|
||||||
task.credits_cost = round(float(task.credits_cost or 0) + media_billing.total_charged, 2)
|
|
||||||
|
|
||||||
|
if retrying_group_children:
|
||||||
|
await db.flush()
|
||||||
|
await aggregate_main_task_status(db, parent_task_id=str(task.id))
|
||||||
|
|
||||||
|
refreshed_task_id = str(task.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
failed_enqueue_ids = await enqueue_created_generation_tasks(db, task_ids=enqueue_ids) if enqueue_ids else []
|
||||||
|
failed_download_enqueue_ids: list[str] = []
|
||||||
try:
|
if download_retry_ids:
|
||||||
chatapi_create_generation_task.delay(task.id)
|
from app.tasks.generation_download_tasks import enqueue_download_task
|
||||||
except Exception as exc:
|
for target_id in download_retry_ids:
|
||||||
await mark_chat_generation_task_failed_and_refund_once(
|
target_result = await db.execute(
|
||||||
db,
|
select(ChatGenerationTask).where(
|
||||||
task_id=task.id,
|
ChatGenerationTask.id == target_id,
|
||||||
error_message=f"任务队列投递失败: {exc}",
|
ChatGenerationTask.deleted_at.is_(None),
|
||||||
pipeline_stage="failed",
|
).limit(1)
|
||||||
)
|
)
|
||||||
await db.commit()
|
target = target_result.scalar_one_or_none()
|
||||||
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
|
if not target or not await enqueue_download_task(db, target, recover=True, reason="manual_retry"):
|
||||||
|
failed_download_enqueue_ids.append(target_id)
|
||||||
|
|
||||||
|
requested_enqueue_count = len(enqueue_ids) + len(download_retry_ids)
|
||||||
|
failed_total_count = len(failed_enqueue_ids) + len(failed_download_enqueue_ids)
|
||||||
|
if requested_enqueue_count and failed_total_count == requested_enqueue_count:
|
||||||
|
raise HTTPException(status_code=503, detail="任务状态已重置,但任务队列投递全部失败,将由恢复任务继续处理")
|
||||||
|
|
||||||
|
refreshed = await db.execute(
|
||||||
|
select(ChatGenerationTask).where(ChatGenerationTask.id == refreshed_task_id).limit(1)
|
||||||
|
)
|
||||||
|
refreshed_task = refreshed.scalar_one_or_none()
|
||||||
|
if not refreshed_task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
return GenerationAIRetryOut(
|
return GenerationAIRetryOut(
|
||||||
id=task.id,
|
id=refreshed_task.id,
|
||||||
status=task.status,
|
status=refreshed_task.status,
|
||||||
pipeline_stage=task.pipeline_stage,
|
pipeline_stage=refreshed_task.pipeline_stage,
|
||||||
message="任务已重新扣费并重新投递",
|
message=(
|
||||||
|
f"请求重试 {len(retry_targets)} 个任务,成功投递 {max(0, requested_enqueue_count - failed_total_count)} 个,"
|
||||||
|
f"投递失败 {failed_total_count} 个"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
@@ -9,7 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.enums.common import ModuleProjectStatusEnum
|
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
|
||||||
|
from app.enums.generation_task import GenerationOwnerType
|
||||||
|
from app.enums.credit_record import (
|
||||||
|
CreditRecordBillingScene,
|
||||||
|
CreditRecordChargeKind,
|
||||||
|
CreditRecordOwnerType,
|
||||||
|
)
|
||||||
|
from app.enums.llm_billing import LlmBillingConfigKey
|
||||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||||
from app.schemas.hot_opening_replicate import (
|
from app.schemas.hot_opening_replicate import (
|
||||||
HotOpeningActionOut,
|
HotOpeningActionOut,
|
||||||
@@ -41,11 +49,21 @@ from app.services.hot_opening_replicate_service import (
|
|||||||
update_hot_opening_material_input,
|
update_hot_opening_material_input,
|
||||||
update_hot_opening_video_prompt_schema,
|
update_hot_opening_video_prompt_schema,
|
||||||
)
|
)
|
||||||
from app.services.module_generation_log_service import log_module_error
|
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||||
|
from app.services.llm_billing import (
|
||||||
|
LlmBillingContext,
|
||||||
|
log_celery_dispatch_compensated,
|
||||||
|
log_celery_dispatch_failure,
|
||||||
|
log_celery_dispatch_start,
|
||||||
|
log_celery_dispatch_success,
|
||||||
|
)
|
||||||
from app.services.module_async_recovery_service import (
|
from app.services.module_async_recovery_service import (
|
||||||
|
OBJECT_MODULE_STEP,
|
||||||
TASK_HOT_IMAGE_PROMPT,
|
TASK_HOT_IMAGE_PROMPT,
|
||||||
TASK_HOT_VIDEO_PROMPT,
|
TASK_HOT_VIDEO_PROMPT,
|
||||||
|
has_live_object_lock,
|
||||||
register_module_step_task,
|
register_module_step_task,
|
||||||
|
remove_active_task,
|
||||||
)
|
)
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||||
@@ -137,6 +155,47 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
|
|||||||
exc=exc,
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _prompt_dispatch_billing_context(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
step_code: str,
|
||||||
|
attempt_no: int,
|
||||||
|
celery_task_id: str,
|
||||||
|
) -> LlmBillingContext:
|
||||||
|
is_image = step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
|
||||||
|
return LlmBillingContext(
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
|
||||||
|
owner_id=step_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||||
|
billing_scene=(
|
||||||
|
CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value
|
||||||
|
if is_image
|
||||||
|
else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
|
||||||
|
),
|
||||||
|
source_module=MODULE,
|
||||||
|
source_project_id=project_id,
|
||||||
|
source_step_id=step_id,
|
||||||
|
source_step_code=step_code,
|
||||||
|
related_id=step_id,
|
||||||
|
hold_config_key=(
|
||||||
|
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||||
|
if is_image
|
||||||
|
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||||
|
),
|
||||||
|
description_prefix=(
|
||||||
|
"爆款开头复刻图片AI提词优化"
|
||||||
|
if is_image
|
||||||
|
else "爆款开头复刻视频提词优化"
|
||||||
|
),
|
||||||
|
trace_id=f"hot-opening-prompt:{step_id}:attempt:{attempt_no}",
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _reload_project_detail(
|
async def _reload_project_detail(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
@@ -160,10 +219,26 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
step_id: str | None,
|
step_id: str | None,
|
||||||
message: str,
|
message: str,
|
||||||
|
billing_context: LlmBillingContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
|
"""Celery 投递失败后补偿步骤和冻结积分,避免一直 processing。"""
|
||||||
|
if billing_context is not None:
|
||||||
|
log_celery_dispatch_failure(billing_context, error=message)
|
||||||
|
compensated = False
|
||||||
if step_id:
|
if step_id:
|
||||||
try:
|
try:
|
||||||
|
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||||
|
log_module_error(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
|
||||||
|
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
|
||||||
await mark_hot_opening_step_dispatch_failed(
|
await mark_hot_opening_step_dispatch_failed(
|
||||||
db,
|
db,
|
||||||
current_user=_user_context(current_user),
|
current_user=_user_context(current_user),
|
||||||
@@ -172,6 +247,23 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
error_message=message,
|
error_message=message,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
compensated = True
|
||||||
|
if billing_context is not None:
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=message)
|
||||||
|
try:
|
||||||
|
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||||
|
except Exception as cleanup_exc:
|
||||||
|
_log_api_error(
|
||||||
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
message="Celery 投递补偿完成,但清理 active registry 失败",
|
||||||
|
detail={"dispatch_error": message},
|
||||||
|
exc=cleanup_exc,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
_log_api_error(
|
_log_api_error(
|
||||||
@@ -190,12 +282,89 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
user_id=_safe_user_id(current_user),
|
user_id=_safe_user_id(current_user),
|
||||||
message=message,
|
message=message,
|
||||||
detail={"reason": "celery_dispatch_failed"},
|
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
|
||||||
error=message,
|
error=message,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=503, detail=message)
|
raise HTTPException(status_code=503, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_prompt_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
step_code: str,
|
||||||
|
task_name: str,
|
||||||
|
celery_task: Any,
|
||||||
|
celery_task_id: str,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
error_prefix: str,
|
||||||
|
) -> None:
|
||||||
|
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
|
||||||
|
registry_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
await register_module_step_task(
|
||||||
|
module=MODULE,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_code=step_code,
|
||||||
|
task_name=task_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
registry_error = exc
|
||||||
|
log_module_error(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
|
||||||
|
detail={"channel": "active_registry"},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
celery_task.apply_async(
|
||||||
|
args=[project_id, step_id],
|
||||||
|
queue="gen_chatapi_create",
|
||||||
|
countdown=0,
|
||||||
|
task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
celery_error = exc
|
||||||
|
|
||||||
|
if celery_error is None:
|
||||||
|
log_celery_dispatch_success(billing_context)
|
||||||
|
return
|
||||||
|
if registry_error is None:
|
||||||
|
log_celery_dispatch_failure(
|
||||||
|
billing_context,
|
||||||
|
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
|
||||||
|
)
|
||||||
|
log_module_event_file(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="Celery 直投失败,任务将由 active registry 恢复投递",
|
||||||
|
detail={"recoverable": True, "celery_task_id": celery_task_id},
|
||||||
|
error=str(celery_error),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _mark_dispatch_failed_and_raise(
|
||||||
|
db,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})",
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/spec",
|
"/spec",
|
||||||
response_model=HotOpeningSpecOut,
|
response_model=HotOpeningSpecOut,
|
||||||
@@ -282,29 +451,17 @@ async def create_task(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
try:
|
log_module_event_file(
|
||||||
project = await create_hot_opening_project(db, current_user, req)
|
module=MODULE,
|
||||||
project_id_value = str(project.id)
|
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||||
await bind_upload_resources(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
message="拦截爆款开头复刻 V1 创建请求",
|
||||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
detail={"api_version": "v1", "flow_version": "v1"},
|
||||||
source_id=project_id_value,
|
)
|
||||||
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
|
raise HTTPException(
|
||||||
urls=[req.material_video_url, req.material_image_url],
|
status_code=410,
|
||||||
allow_common_migrate=True,
|
detail="V1 创建流程已停止,请使用 V2 API",
|
||||||
)
|
)
|
||||||
await db.commit()
|
|
||||||
except HTTPException:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
await db.rollback()
|
|
||||||
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
|
|
||||||
|
|
||||||
return await _reload_project_detail(db, current_user, project_id_value)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -525,6 +682,17 @@ async def generate_image_prompt(
|
|||||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
|
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
|
||||||
project_id_value = str(project.id)
|
project_id_value = str(project.id)
|
||||||
step_id_value = str(step.id)
|
step_id_value = str(step.id)
|
||||||
|
user_id_value = str(project.user_id)
|
||||||
|
attempt_no_value = int(step.version or 1)
|
||||||
|
celery_task_id = f"hot-opening:image-prompt:{step_id_value}"
|
||||||
|
billing_context = _prompt_dispatch_billing_context(
|
||||||
|
user_id=user_id_value,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||||
|
attempt_no=attempt_no_value,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -536,22 +704,18 @@ async def generate_image_prompt(
|
|||||||
|
|
||||||
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
|
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
|
||||||
|
|
||||||
await register_module_step_task(
|
log_celery_dispatch_start(billing_context)
|
||||||
module=MODULE,
|
await _dispatch_prompt_task(
|
||||||
project_id=project_id_value,
|
|
||||||
step_id=step_id_value,
|
|
||||||
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
|
||||||
task_name=TASK_HOT_IMAGE_PROMPT,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
|
||||||
except Exception as exc:
|
|
||||||
await _mark_dispatch_failed_and_raise(
|
|
||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
message=f"图片提词任务投递失败: {exc}",
|
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||||
|
task_name=TASK_HOT_IMAGE_PROMPT,
|
||||||
|
celery_task=start_image_prompt_optimize,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
error_prefix="图片提词任务投递失败",
|
||||||
)
|
)
|
||||||
|
|
||||||
return HotOpeningActionOut(
|
return HotOpeningActionOut(
|
||||||
@@ -610,14 +774,21 @@ async def generate_image(
|
|||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
chatapi_create_generation_task.apply_async(
|
||||||
|
args=[chat_task_id_value],
|
||||||
|
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||||
|
queue="gen_chatapi_create",
|
||||||
|
countdown=0,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await _mark_dispatch_failed_and_raise(
|
_log_api_error(
|
||||||
db,
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
message=f"图片生成任务投递失败: {exc}",
|
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||||
|
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||||
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
return HotOpeningActionOut(
|
return HotOpeningActionOut(
|
||||||
@@ -661,6 +832,17 @@ async def generate_video_prompt(
|
|||||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
||||||
project_id_value = str(project.id)
|
project_id_value = str(project.id)
|
||||||
step_id_value = str(step.id)
|
step_id_value = str(step.id)
|
||||||
|
user_id_value = str(project.user_id)
|
||||||
|
attempt_no_value = int(step.version or 1)
|
||||||
|
celery_task_id = f"hot-opening:video-prompt:{step_id_value}"
|
||||||
|
billing_context = _prompt_dispatch_billing_context(
|
||||||
|
user_id=user_id_value,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||||
|
attempt_no=attempt_no_value,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -672,22 +854,18 @@ async def generate_video_prompt(
|
|||||||
|
|
||||||
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
|
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
|
||||||
|
|
||||||
await register_module_step_task(
|
log_celery_dispatch_start(billing_context)
|
||||||
module=MODULE,
|
await _dispatch_prompt_task(
|
||||||
project_id=project_id_value,
|
|
||||||
step_id=step_id_value,
|
|
||||||
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
|
||||||
task_name=TASK_HOT_VIDEO_PROMPT,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
|
||||||
except Exception as exc:
|
|
||||||
await _mark_dispatch_failed_and_raise(
|
|
||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
message=f"视频提词任务投递失败: {exc}",
|
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||||
|
task_name=TASK_HOT_VIDEO_PROMPT,
|
||||||
|
celery_task=start_video_prompt_optimize,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
error_prefix="视频提词任务投递失败",
|
||||||
)
|
)
|
||||||
|
|
||||||
return HotOpeningActionOut(
|
return HotOpeningActionOut(
|
||||||
@@ -747,14 +925,21 @@ async def generate_video(
|
|||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
chatapi_create_generation_task.apply_async(
|
||||||
|
args=[chat_task_id_value],
|
||||||
|
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||||
|
queue="gen_chatapi_create",
|
||||||
|
countdown=0,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await _mark_dispatch_failed_and_raise(
|
_log_api_error(
|
||||||
db,
|
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
message=f"视频生成任务投递失败: {exc}",
|
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||||
|
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||||
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
return HotOpeningActionOut(
|
return HotOpeningActionOut(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def list_active_engines(
|
|||||||
"""Public endpoint returning active image engine capabilities."""
|
"""Public endpoint returning active image engine capabilities."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ImageEngine)
|
select(ImageEngine)
|
||||||
.where(ImageEngine.is_active == True)
|
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||||
.order_by(ImageEngine.priority.desc())
|
.order_by(ImageEngine.priority.desc())
|
||||||
)
|
)
|
||||||
engines = result.scalars().all()
|
engines = result.scalars().all()
|
||||||
@@ -45,5 +45,9 @@ async def list_active_engines(
|
|||||||
"supported_sizes": sizes,
|
"supported_sizes": sizes,
|
||||||
"default_size": e.default_size,
|
"default_size": e.default_size,
|
||||||
"max_image_count": e.max_image_count,
|
"max_image_count": e.max_image_count,
|
||||||
|
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
|
||||||
|
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
|
||||||
|
"multi_image_max_images": int(getattr(e, "multi_image_max_images", 15) or 15),
|
||||||
|
"max_reference_image_count": int(getattr(e, "max_reference_image_count", 14) or 0),
|
||||||
})
|
})
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
@@ -38,10 +38,10 @@ from app.schemas.private_portrait import (
|
|||||||
build_private_portrait_enum_meta,
|
build_private_portrait_enum_meta,
|
||||||
)
|
)
|
||||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||||
|
from app.services.private_portrait.quota_service import get_user_private_portrait_config
|
||||||
from app.services.private_portrait.asset_service import (
|
from app.services.private_portrait.asset_service import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
asset_to_out,
|
asset_to_out,
|
||||||
get_user_private_portrait_config,
|
|
||||||
get_validate_session,
|
get_validate_session,
|
||||||
handle_validate_callback,
|
handle_validate_callback,
|
||||||
list_assets,
|
list_assets,
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ from app.schemas.private_portrait import (
|
|||||||
build_private_portrait_enum_meta,
|
build_private_portrait_enum_meta,
|
||||||
)
|
)
|
||||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||||
|
from app.services.private_portrait.quota_service import get_user_private_portrait_config
|
||||||
from app.services.private_portrait.asset_service import (
|
from app.services.private_portrait.asset_service import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
asset_to_out,
|
asset_to_out,
|
||||||
get_user_private_portrait_config,
|
|
||||||
list_assets,
|
list_assets,
|
||||||
list_selectable_assets,
|
list_selectable_assets,
|
||||||
soft_delete_asset,
|
soft_delete_asset,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.models.project import Project
|
|||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.schemas.project import ProjectCreate, ProjectOut
|
from app.schemas.project import ProjectCreate, ProjectOut
|
||||||
from app.services.resource_accounting_service import soft_delete_generation_record_resources
|
from app.services.resource_accounting_service import soft_delete_generation_record_resources
|
||||||
|
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
@@ -88,6 +89,10 @@ async def delete_project(
|
|||||||
)
|
)
|
||||||
records = list(records_result.scalars().all())
|
records = list(records_result.scalars().all())
|
||||||
record_ids = [record.id for record in records]
|
record_ids = [record.id for record in records]
|
||||||
|
await assert_no_recoverable_failed_upscale_tasks(
|
||||||
|
db,
|
||||||
|
generation_record_ids=record_ids,
|
||||||
|
)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
project.deleted_at = now
|
project.deleted_at = now
|
||||||
|
|||||||
@@ -2,14 +2,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.enums.celery_queue import CeleryQueue
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.enums.common import ModuleEventTypeEnum
|
||||||
|
from app.enums.generation_task import GenerationOwnerType
|
||||||
|
from app.enums.credit_record import (
|
||||||
|
CreditRecordBillingScene,
|
||||||
|
CreditRecordChargeKind,
|
||||||
|
CreditRecordOwnerType,
|
||||||
|
CreditRecordSourceStepCode,
|
||||||
|
)
|
||||||
|
from app.enums.llm_billing import LlmBillingConfigKey
|
||||||
from app.enums.shot_replicate import (
|
from app.enums.shot_replicate import (
|
||||||
ModuleCodeEnum,
|
ModuleCodeEnum,
|
||||||
ShotAnalysisStatusEnum,
|
ShotAnalysisStatusEnum,
|
||||||
@@ -23,7 +34,6 @@ from app.enums.shot_replicate import (
|
|||||||
)
|
)
|
||||||
from app.schemas.shot_replicate import (
|
from app.schemas.shot_replicate import (
|
||||||
ShotReplicateActionOut,
|
ShotReplicateActionOut,
|
||||||
ShotReplicateDeleteOut,
|
|
||||||
ShotReplicateGenerateImagePromptRequest,
|
ShotReplicateGenerateImagePromptRequest,
|
||||||
ShotReplicateGenerateImageRequest,
|
ShotReplicateGenerateImageRequest,
|
||||||
ShotReplicateGenerateVideoPromptRequest,
|
ShotReplicateGenerateVideoPromptRequest,
|
||||||
@@ -52,7 +62,6 @@ from app.schemas.shot_replicate import (
|
|||||||
)
|
)
|
||||||
from app.services.shot_replicate_flow_service import (
|
from app.services.shot_replicate_flow_service import (
|
||||||
_get_project_for_user,
|
_get_project_for_user,
|
||||||
create_shot_replicate_project_from_segment,
|
|
||||||
generate_image_from_prompt,
|
generate_image_from_prompt,
|
||||||
generate_video_from_prompt,
|
generate_video_from_prompt,
|
||||||
mark_shot_replicate_step_dispatch_failed,
|
mark_shot_replicate_step_dispatch_failed,
|
||||||
@@ -64,14 +73,17 @@ from app.services.shot_replicate_flow_service import (
|
|||||||
update_shot_replicate_video_prompt_schema,
|
update_shot_replicate_video_prompt_schema,
|
||||||
)
|
)
|
||||||
from app.services.shot_replicate_taskset_service import (
|
from app.services.shot_replicate_taskset_service import (
|
||||||
|
build_task_set_analysis_billing_context,
|
||||||
create_custom_segment,
|
create_custom_segment,
|
||||||
create_segments_by_ai,
|
create_segments_by_ai,
|
||||||
create_task_set,
|
create_task_set,
|
||||||
delete_segment,
|
delete_segment,
|
||||||
delete_task_set,
|
delete_task_set,
|
||||||
get_segment_for_user,
|
|
||||||
list_segments,
|
list_segments,
|
||||||
list_task_sets,
|
list_task_sets,
|
||||||
|
mark_custom_segment_split_dispatch_failed,
|
||||||
|
mark_segment_analysis_dispatch_failed,
|
||||||
|
mark_task_set_analysis_dispatch_failed,
|
||||||
prepare_reanalyze_segment,
|
prepare_reanalyze_segment,
|
||||||
prepare_reanalyze_task_set,
|
prepare_reanalyze_task_set,
|
||||||
prepare_retry_split_segment,
|
prepare_retry_split_segment,
|
||||||
@@ -79,13 +91,20 @@ from app.services.shot_replicate_taskset_service import (
|
|||||||
task_set_detail,
|
task_set_detail,
|
||||||
)
|
)
|
||||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||||
|
from app.services.llm_billing import (
|
||||||
|
LlmBillingContext,
|
||||||
|
log_celery_dispatch_compensated,
|
||||||
|
log_celery_dispatch_failure,
|
||||||
|
log_celery_dispatch_start,
|
||||||
|
log_celery_dispatch_success,
|
||||||
|
)
|
||||||
from app.services.module_async_recovery_service import (
|
from app.services.module_async_recovery_service import (
|
||||||
|
OBJECT_MODULE_STEP,
|
||||||
TASK_SHOT_IMAGE_PROMPT,
|
TASK_SHOT_IMAGE_PROMPT,
|
||||||
TASK_SHOT_VIDEO_PROMPT,
|
TASK_SHOT_VIDEO_PROMPT,
|
||||||
|
has_live_object_lock,
|
||||||
register_module_step_task,
|
register_module_step_task,
|
||||||
register_shot_segment_analysis_task,
|
remove_active_task,
|
||||||
register_shot_split_task,
|
|
||||||
register_shot_task_set_analysis_task,
|
|
||||||
)
|
)
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||||
@@ -190,6 +209,83 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=503, detail=message)
|
raise HTTPException(status_code=503, detail=message)
|
||||||
|
|
||||||
|
def _prompt_dispatch_billing_context(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
step_code: str,
|
||||||
|
attempt_no: int,
|
||||||
|
celery_task_id: str,
|
||||||
|
) -> LlmBillingContext:
|
||||||
|
is_image = step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
|
||||||
|
return LlmBillingContext(
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
|
||||||
|
owner_id=step_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||||
|
billing_scene=(
|
||||||
|
CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value
|
||||||
|
if is_image
|
||||||
|
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
|
||||||
|
),
|
||||||
|
source_module=MODULE,
|
||||||
|
source_project_id=project_id,
|
||||||
|
source_step_id=step_id,
|
||||||
|
source_step_code=step_code,
|
||||||
|
related_id=step_id,
|
||||||
|
hold_config_key=(
|
||||||
|
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||||
|
if is_image
|
||||||
|
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||||
|
),
|
||||||
|
description_prefix=(
|
||||||
|
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
|
||||||
|
),
|
||||||
|
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_dispatch_billing_context(
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
owner_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
task_set_id: str,
|
||||||
|
is_segment: bool,
|
||||||
|
celery_task_id: str,
|
||||||
|
) -> LlmBillingContext:
|
||||||
|
return LlmBillingContext(
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=(
|
||||||
|
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
|
||||||
|
if is_segment
|
||||||
|
else CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
|
||||||
|
),
|
||||||
|
owner_id=owner_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
|
||||||
|
billing_scene=(
|
||||||
|
CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value
|
||||||
|
if is_segment
|
||||||
|
else CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value
|
||||||
|
),
|
||||||
|
source_module=MODULE,
|
||||||
|
source_project_id=task_set_id,
|
||||||
|
source_step_id=owner_id,
|
||||||
|
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||||
|
related_id=owner_id,
|
||||||
|
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||||
|
description_prefix=(
|
||||||
|
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
|
||||||
|
),
|
||||||
|
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||||
project = await _get_project_for_user(
|
project = await _get_project_for_user(
|
||||||
db,
|
db,
|
||||||
@@ -208,9 +304,25 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
step_id: str | None,
|
step_id: str | None,
|
||||||
message: str,
|
message: str,
|
||||||
|
billing_context: LlmBillingContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if billing_context is not None:
|
||||||
|
log_celery_dispatch_failure(billing_context, error=message)
|
||||||
|
compensated = False
|
||||||
if step_id:
|
if step_id:
|
||||||
try:
|
try:
|
||||||
|
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||||
|
log_module_error(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
|
||||||
|
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
|
||||||
await mark_shot_replicate_step_dispatch_failed(
|
await mark_shot_replicate_step_dispatch_failed(
|
||||||
db,
|
db,
|
||||||
current_user=_user_context(current_user),
|
current_user=_user_context(current_user),
|
||||||
@@ -219,6 +331,23 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
error_message=message,
|
error_message=message,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
compensated = True
|
||||||
|
if billing_context is not None:
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=message)
|
||||||
|
try:
|
||||||
|
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||||
|
except Exception as cleanup_exc:
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
message="Celery 投递补偿完成,但清理 active registry 失败",
|
||||||
|
detail={"dispatch_error": message},
|
||||||
|
exc=cleanup_exc,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
_log_api_error(
|
_log_api_error(
|
||||||
@@ -237,12 +366,89 @@ async def _mark_dispatch_failed_and_raise(
|
|||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
user_id=_safe_user_id(current_user),
|
user_id=_safe_user_id(current_user),
|
||||||
message=message,
|
message=message,
|
||||||
detail={"reason": "celery_dispatch_failed"},
|
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
|
||||||
error=message,
|
error=message,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=503, detail=message)
|
raise HTTPException(status_code=503, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_prompt_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
step_code: str,
|
||||||
|
task_name: str,
|
||||||
|
celery_task: Any,
|
||||||
|
celery_task_id: str,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
error_prefix: str,
|
||||||
|
) -> None:
|
||||||
|
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
|
||||||
|
registry_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
await register_module_step_task(
|
||||||
|
module=MODULE,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_code=step_code,
|
||||||
|
task_name=task_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
registry_error = exc
|
||||||
|
log_module_error(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
|
||||||
|
detail={"channel": "active_registry"},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
celery_task.apply_async(
|
||||||
|
args=[project_id, step_id],
|
||||||
|
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||||
|
countdown=0,
|
||||||
|
task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
celery_error = exc
|
||||||
|
|
||||||
|
if celery_error is None:
|
||||||
|
log_celery_dispatch_success(billing_context)
|
||||||
|
return
|
||||||
|
if registry_error is None:
|
||||||
|
log_celery_dispatch_failure(
|
||||||
|
billing_context,
|
||||||
|
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
|
||||||
|
)
|
||||||
|
log_module_event_file(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=_safe_user_id(current_user),
|
||||||
|
message="Celery 直投失败,任务将由 active registry 恢复投递",
|
||||||
|
detail={"recoverable": True, "celery_task_id": celery_task_id},
|
||||||
|
error=str(celery_error),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _mark_dispatch_failed_and_raise(
|
||||||
|
db,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})",
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/spec",
|
"/spec",
|
||||||
response_model=ShotReplicateSpecOut,
|
response_model=ShotReplicateSpecOut,
|
||||||
@@ -330,8 +536,16 @@ async def create_shot_task_set(
|
|||||||
):
|
):
|
||||||
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
|
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
|
||||||
try:
|
try:
|
||||||
task_set = await create_task_set(db, current_user=current_user, req=req)
|
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
|
||||||
task_set_id = task_set.id
|
task_set_id = str(task_set.id)
|
||||||
|
if not created_new:
|
||||||
|
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||||
|
await db.rollback()
|
||||||
|
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||||
|
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
|
||||||
|
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
|
||||||
|
billing_context = build_task_set_analysis_billing_context(task_set)
|
||||||
|
billing_context.celery_task_id = celery_task_id
|
||||||
await bind_upload_resources(
|
await bind_upload_resources(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -351,12 +565,19 @@ async def create_shot_task_set(
|
|||||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
|
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
|
||||||
|
|
||||||
|
log_celery_dispatch_start(billing_context)
|
||||||
try:
|
try:
|
||||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||||
|
|
||||||
await register_shot_task_set_analysis_task(task_set_id)
|
analyze_original_video.apply_async(
|
||||||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
|
args=[task_set_id, analysis_attempt_no],
|
||||||
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
|
countdown=0,
|
||||||
|
task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
log_celery_dispatch_success(billing_context)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||||
_log_api_error(
|
_log_api_error(
|
||||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
@@ -365,6 +586,27 @@ async def create_shot_task_set(
|
|||||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||||
exc=exc,
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
compensated = await mark_task_set_analysis_dispatch_failed(
|
||||||
|
db,
|
||||||
|
current_user=_user_context(current_user),
|
||||||
|
task_set_id=task_set_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
|
error_message=f"拆镜分析任务投递失败: {exc}",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if compensated:
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||||
|
except Exception as mark_exc:
|
||||||
|
await db.rollback()
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=task_set_id,
|
||||||
|
message="拆镜分析任务投递失败后补偿失败",
|
||||||
|
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||||
|
exc=mark_exc,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
|
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
|
||||||
|
|
||||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||||
@@ -432,7 +674,7 @@ async def get_shot_task_set(
|
|||||||
"/task-sets/{task_set_id}/reanalyze",
|
"/task-sets/{task_set_id}/reanalyze",
|
||||||
response_model=ShotReanalyzeOut,
|
response_model=ShotReanalyzeOut,
|
||||||
summary="重新投递原视频 AI 分析任务",
|
summary="重新投递原视频 AI 分析任务",
|
||||||
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video。",
|
description="仅用于重新处理原视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递。",
|
||||||
)
|
)
|
||||||
async def reanalyze_task_set(
|
async def reanalyze_task_set(
|
||||||
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
|
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
|
||||||
@@ -446,9 +688,18 @@ async def reanalyze_task_set(
|
|||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
task_set_id=task_set_id,
|
task_set_id=task_set_id,
|
||||||
force=req.force,
|
|
||||||
reason=req.reason,
|
reason=req.reason,
|
||||||
)
|
)
|
||||||
|
analysis_attempt_no = int(out.analysis_attempt_no)
|
||||||
|
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
|
||||||
|
billing_context = _analysis_dispatch_billing_context(
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
owner_id=task_set_id,
|
||||||
|
attempt_no=analysis_attempt_no,
|
||||||
|
task_set_id=task_set_id,
|
||||||
|
is_segment=False,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -474,11 +725,17 @@ async def reanalyze_task_set(
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
|
||||||
|
|
||||||
|
log_celery_dispatch_start(billing_context)
|
||||||
try:
|
try:
|
||||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||||
|
|
||||||
await register_shot_task_set_analysis_task(task_set_id)
|
analyze_original_video.apply_async(
|
||||||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
|
args=[task_set_id, analysis_attempt_no],
|
||||||
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
|
countdown=0,
|
||||||
|
task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
log_celery_dispatch_success(billing_context)
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
|
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
|
||||||
@@ -488,6 +745,7 @@ async def reanalyze_task_set(
|
|||||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
|
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||||
_log_api_error(
|
_log_api_error(
|
||||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
@@ -496,6 +754,27 @@ async def reanalyze_task_set(
|
|||||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||||
exc=exc,
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
compensated = await mark_task_set_analysis_dispatch_failed(
|
||||||
|
db,
|
||||||
|
current_user=_user_context(current_user),
|
||||||
|
task_set_id=task_set_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
|
error_message=f"原视频再次分析任务投递失败: {exc}",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if compensated:
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||||
|
except Exception as mark_exc:
|
||||||
|
await db.rollback()
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=task_set_id,
|
||||||
|
message="原视频再次分析任务投递失败后补偿失败",
|
||||||
|
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||||
|
exc=mark_exc,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
|
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
|
||||||
out.message = "原视频再次分析任务已提交"
|
out.message = "原视频再次分析任务已提交"
|
||||||
return out
|
return out
|
||||||
@@ -532,8 +811,7 @@ async def split_by_ai(
|
|||||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||||
|
|
||||||
for segment_id in segment_ids:
|
for segment_id in segment_ids:
|
||||||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
|
||||||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -564,8 +842,38 @@ async def split_custom(
|
|||||||
|
|
||||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||||
|
|
||||||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
try:
|
||||||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
|
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
|
||||||
|
except Exception as exc:
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=task_set_id,
|
||||||
|
step_id=segment_id,
|
||||||
|
message=f"自定义拆镜切片任务投递失败: {exc}",
|
||||||
|
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await mark_custom_segment_split_dispatch_failed(
|
||||||
|
db,
|
||||||
|
current_user=_user_context(current_user),
|
||||||
|
segment_id=segment_id,
|
||||||
|
error_message=f"自定义拆镜切片任务投递失败: {exc}",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as mark_exc:
|
||||||
|
await db.rollback()
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=task_set_id,
|
||||||
|
step_id=segment_id,
|
||||||
|
message="自定义拆镜切片投递失败后补偿失败",
|
||||||
|
detail={"segment_id": segment_id, "task_set_id": task_set_id},
|
||||||
|
exc=mark_exc,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail=f"自定义拆镜切片任务投递失败: {exc}")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -617,7 +925,7 @@ async def get_segment(
|
|||||||
"/segments/{segment_id}/reanalyze",
|
"/segments/{segment_id}/reanalyze",
|
||||||
response_model=ShotReanalyzeOut,
|
response_model=ShotReanalyzeOut,
|
||||||
summary="重新投递切片视频 AI 分析任务",
|
summary="重新投递切片视频 AI 分析任务",
|
||||||
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video。",
|
description="仅用于重新处理自定义切片视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递。",
|
||||||
)
|
)
|
||||||
async def reanalyze_segment(
|
async def reanalyze_segment(
|
||||||
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
||||||
@@ -631,10 +939,19 @@ async def reanalyze_segment(
|
|||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
segment_id=segment_id,
|
segment_id=segment_id,
|
||||||
force=req.force,
|
|
||||||
reason=req.reason,
|
reason=req.reason,
|
||||||
)
|
)
|
||||||
task_set_id = out.task_set_id
|
task_set_id = str(out.task_set_id)
|
||||||
|
analysis_attempt_no = int(out.analysis_attempt_no)
|
||||||
|
celery_task_id = f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}"
|
||||||
|
billing_context = _analysis_dispatch_billing_context(
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
owner_id=segment_id,
|
||||||
|
attempt_no=analysis_attempt_no,
|
||||||
|
task_set_id=task_set_id,
|
||||||
|
is_segment=True,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -660,11 +977,17 @@ async def reanalyze_segment(
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
|
||||||
|
|
||||||
|
log_celery_dispatch_start(billing_context)
|
||||||
try:
|
try:
|
||||||
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
||||||
|
|
||||||
await register_shot_segment_analysis_task(segment_id, task_set_id=task_set_id)
|
analyze_custom_segment_video.apply_async(
|
||||||
analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", countdown=0)
|
args=[segment_id, analysis_attempt_no],
|
||||||
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
|
countdown=0,
|
||||||
|
task_id=celery_task_id,
|
||||||
|
)
|
||||||
|
log_celery_dispatch_success(billing_context)
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
|
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
|
||||||
@@ -675,6 +998,7 @@ async def reanalyze_segment(
|
|||||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
|
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||||
_log_api_error(
|
_log_api_error(
|
||||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
@@ -684,6 +1008,28 @@ async def reanalyze_segment(
|
|||||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
||||||
exc=exc,
|
exc=exc,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
compensated = await mark_segment_analysis_dispatch_failed(
|
||||||
|
db,
|
||||||
|
current_user=_user_context(current_user),
|
||||||
|
segment_id=segment_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
|
error_message=f"切片视频再次分析任务投递失败: {exc}",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if compensated:
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||||
|
except Exception as mark_exc:
|
||||||
|
await db.rollback()
|
||||||
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=task_set_id,
|
||||||
|
step_id=segment_id,
|
||||||
|
message="切片视频再次分析任务投递失败后补偿失败",
|
||||||
|
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
||||||
|
exc=mark_exc,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
|
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
|
||||||
out.message = "切片视频再次分析任务已提交"
|
out.message = "切片视频再次分析任务已提交"
|
||||||
return out
|
return out
|
||||||
@@ -730,10 +1076,9 @@ async def retry_split_segment(
|
|||||||
try:
|
try:
|
||||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||||
|
|
||||||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
|
||||||
split_one_segment.apply_async(
|
split_one_segment.apply_async(
|
||||||
args=[segment_id],
|
args=[segment_id],
|
||||||
queue="gen_result_download",
|
queue=CeleryQueue.GEN_SHOT_SPLIT.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||||
)
|
)
|
||||||
@@ -748,7 +1093,7 @@ async def retry_split_segment(
|
|||||||
"segment_id": segment_id,
|
"segment_id": segment_id,
|
||||||
"task_set_id": task_set_id,
|
"task_set_id": task_set_id,
|
||||||
"task": "split_one_segment",
|
"task": "split_one_segment",
|
||||||
"queue": "gen_result_download",
|
"queue": CeleryQueue.GEN_SHOT_SPLIT.value,
|
||||||
"request": req.model_dump(),
|
"request": req.model_dump(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -861,24 +1206,17 @@ async def create_replication_project_from_segment(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
try:
|
log_module_event_file(
|
||||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
module=MODULE,
|
||||||
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
|
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||||
project_id = project.id
|
user_id=current_user.id,
|
||||||
await db.commit()
|
step_id=segment_id,
|
||||||
except HTTPException:
|
message="拦截拆镜复刻 V1 创建请求",
|
||||||
await db.rollback()
|
detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
|
||||||
raise
|
)
|
||||||
except Exception as exc:
|
raise HTTPException(
|
||||||
await db.rollback()
|
status_code=410,
|
||||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
|
detail="V1 创建流程已停止,请使用 V2 API",
|
||||||
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
|
|
||||||
|
|
||||||
return ShotReplicateActionOut(
|
|
||||||
message="已从拆镜片段创建复刻项目,素材视频已锁定",
|
|
||||||
project_id=project_id,
|
|
||||||
step_id=None,
|
|
||||||
detail=await _reload_project_detail(db, current_user, project_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1001,7 +1339,18 @@ async def generate_image_prompt(
|
|||||||
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
||||||
try:
|
try:
|
||||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
|
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
|
||||||
project_id_value, step_id_value = project.id, step.id
|
project_id_value, step_id_value = str(project.id), str(step.id)
|
||||||
|
user_id_value = str(project.user_id)
|
||||||
|
attempt_no_value = int(step.version or 1)
|
||||||
|
celery_task_id = f"shot-replicate:image-prompt:{step_id_value}"
|
||||||
|
billing_context = _prompt_dispatch_billing_context(
|
||||||
|
user_id=user_id_value,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||||
|
attempt_no=attempt_no_value,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -1011,19 +1360,21 @@ async def generate_image_prompt(
|
|||||||
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
|
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
|
||||||
|
|
||||||
try:
|
|
||||||
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
|
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
|
||||||
|
|
||||||
await register_module_step_task(
|
log_celery_dispatch_start(billing_context)
|
||||||
module=MODULE,
|
await _dispatch_prompt_task(
|
||||||
|
db,
|
||||||
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||||
task_name=TASK_SHOT_IMAGE_PROMPT,
|
task_name=TASK_SHOT_IMAGE_PROMPT,
|
||||||
|
celery_task=start_image_prompt_optimize,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
error_prefix="图片 AI 提词任务投递失败",
|
||||||
)
|
)
|
||||||
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
|
||||||
except Exception as exc:
|
|
||||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
|
|
||||||
|
|
||||||
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||||
|
|
||||||
@@ -1061,9 +1412,22 @@ async def generate_image(
|
|||||||
try:
|
try:
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
|
|
||||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
chatapi_create_generation_task.apply_async(
|
||||||
|
args=[chat_task_id_value],
|
||||||
|
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||||
|
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||||
|
countdown=0,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}")
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||||
|
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
|
||||||
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||||
|
|
||||||
@@ -1088,7 +1452,18 @@ async def generate_video_prompt(
|
|||||||
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
||||||
try:
|
try:
|
||||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
||||||
project_id_value, step_id_value = project.id, step.id
|
project_id_value, step_id_value = str(project.id), str(step.id)
|
||||||
|
user_id_value = str(project.user_id)
|
||||||
|
attempt_no_value = int(step.version or 1)
|
||||||
|
celery_task_id = f"shot-replicate:video-prompt:{step_id_value}"
|
||||||
|
billing_context = _prompt_dispatch_billing_context(
|
||||||
|
user_id=user_id_value,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||||
|
attempt_no=attempt_no_value,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
@@ -1098,19 +1473,21 @@ async def generate_video_prompt(
|
|||||||
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
|
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
|
||||||
|
|
||||||
try:
|
|
||||||
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
|
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
|
||||||
|
|
||||||
await register_module_step_task(
|
log_celery_dispatch_start(billing_context)
|
||||||
module=MODULE,
|
await _dispatch_prompt_task(
|
||||||
|
db,
|
||||||
|
current_user=current_user,
|
||||||
project_id=project_id_value,
|
project_id=project_id_value,
|
||||||
step_id=step_id_value,
|
step_id=step_id_value,
|
||||||
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||||
task_name=TASK_SHOT_VIDEO_PROMPT,
|
task_name=TASK_SHOT_VIDEO_PROMPT,
|
||||||
|
celery_task=start_video_prompt_optimize,
|
||||||
|
celery_task_id=celery_task_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
error_prefix="视频 AI 提词任务投递失败",
|
||||||
)
|
)
|
||||||
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
|
||||||
except Exception as exc:
|
|
||||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
|
|
||||||
|
|
||||||
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||||
|
|
||||||
@@ -1148,9 +1525,22 @@ async def generate_video(
|
|||||||
try:
|
try:
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
|
|
||||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
chatapi_create_generation_task.apply_async(
|
||||||
|
args=[chat_task_id_value],
|
||||||
|
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||||
|
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||||
|
countdown=0,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}")
|
_log_api_error(
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||||
|
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
|
||||||
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -405,7 +407,7 @@ async def export_team_credit_records(
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
safe_team_name = team.name or "team"
|
safe_team_name = team.name or "team"
|
||||||
filename = f"团队积分_{safe_team_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
encoded_filename = quote(filename)
|
encoded_filename = quote(filename)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def list_active_engines(
|
|||||||
"""Public endpoint returning active video engine capabilities."""
|
"""Public endpoint returning active video engine capabilities."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VideoEngine)
|
select(VideoEngine)
|
||||||
.where(VideoEngine.is_active == True)
|
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||||
.order_by(VideoEngine.priority.desc())
|
.order_by(VideoEngine.priority.desc())
|
||||||
)
|
)
|
||||||
engines = result.scalars().all()
|
engines = result.scalars().all()
|
||||||
@@ -52,6 +52,8 @@ async def list_active_engines(
|
|||||||
"max_image_count": e.max_image_count,
|
"max_image_count": e.max_image_count,
|
||||||
"max_video_count": e.max_video_count,
|
"max_video_count": e.max_video_count,
|
||||||
"max_audio_count": e.max_audio_count,
|
"max_audio_count": e.max_audio_count,
|
||||||
|
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
|
||||||
|
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
|
||||||
"supports_first_last_frame": e.supports_first_last_frame,
|
"supports_first_last_frame": e.supports_first_last_frame,
|
||||||
"supports_universal_reference": e.supports_universal_reference,
|
"supports_universal_reference": e.supports_universal_reference,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v2.hot_opening_replicate import router as hot_opening_router
|
||||||
|
from app.api.v2.shot_replicate import router as shot_replicate_router
|
||||||
|
|
||||||
|
api_router_v2 = APIRouter()
|
||||||
|
api_router_v2.include_router(hot_opening_router)
|
||||||
|
api_router_v2.include_router(shot_replicate_router)
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_current_user, get_db
|
||||||
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.hot_opening_replicate import HotOpeningActionOut, HotOpeningDeleteOut, HotOpeningTaskDetailOut
|
||||||
|
from app.schemas.module_generation_v2 import (
|
||||||
|
HotOpeningTaskCreateV2,
|
||||||
|
ModuleVideoPromptRetryV2,
|
||||||
|
ModuleVideoPromptSchemaUpdateV2,
|
||||||
|
)
|
||||||
|
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||||
|
from app.services.hot_opening_replicate_service import project_to_detail_out
|
||||||
|
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
|
||||||
|
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
|
||||||
|
from app.services.module_generation_v2.config import HOT_OPENING_V2
|
||||||
|
from app.services.module_generation_v2.dispatch_service import (
|
||||||
|
dispatch_video_prompt_v2,
|
||||||
|
ensure_v2_celery_enabled,
|
||||||
|
)
|
||||||
|
from app.services.module_generation_v2.flow_service import (
|
||||||
|
build_v2_video_prompt_billing_context,
|
||||||
|
create_hot_opening_project_v2,
|
||||||
|
delete_project_v2,
|
||||||
|
generate_video_from_prompt_v2,
|
||||||
|
get_v2_project_for_user,
|
||||||
|
is_project_idempotency_conflict,
|
||||||
|
mark_video_prompt_dispatch_failed_v2,
|
||||||
|
rebuild_video_prompt_step_v2,
|
||||||
|
update_video_prompt_schema_v2,
|
||||||
|
)
|
||||||
|
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
|
||||||
|
context = build_v2_video_prompt_billing_context(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=step_version,
|
||||||
|
module=HOT_OPENING_V2.module,
|
||||||
|
display_name=HOT_OPENING_V2.display_name,
|
||||||
|
)
|
||||||
|
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
|
||||||
|
project = await get_v2_project_for_user(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
return await project_to_detail_out(db, project)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_or_mark_failed(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
) -> None:
|
||||||
|
dispatch = await dispatch_video_prompt_v2(
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
if dispatch.recoverable:
|
||||||
|
return
|
||||||
|
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||||
|
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
|
||||||
|
return
|
||||||
|
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||||
|
await mark_video_prompt_dispatch_failed_v2(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=error_message)
|
||||||
|
raise HTTPException(status_code=503, detail=error_message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks", response_model=HotOpeningTaskDetailOut)
|
||||||
|
async def create_task_v2(
|
||||||
|
req: HotOpeningTaskCreateV2 = Body(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||||
|
project_id = str(result.project.id)
|
||||||
|
step_id = str(result.prompt_step.id)
|
||||||
|
created_new = bool(result.created_new)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(result.project.user_id),
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=int(result.prompt_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||||
|
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||||
|
# 同幂等键并发请求由唯一索引收敛;回查已提交项目并按幂等成功返回。
|
||||||
|
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||||
|
project_id = str(result.project.id)
|
||||||
|
step_id = str(result.prompt_step.id)
|
||||||
|
created_new = bool(result.created_new)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(result.project.user_id),
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=int(result.prompt_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
|
||||||
|
|
||||||
|
if created_new:
|
||||||
|
await _dispatch_or_mark_failed(
|
||||||
|
db, project_id=project_id, step_id=step_id, billing_context=billing_context
|
||||||
|
)
|
||||||
|
return await _detail(db, current_user, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{project_id}", response_model=HotOpeningTaskDetailOut)
|
||||||
|
async def get_task_v2(
|
||||||
|
project_id: str = Path(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _detail(db, current_user, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tasks/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||||
|
response_model=HotOpeningActionOut,
|
||||||
|
)
|
||||||
|
async def retry_video_prompt_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
req: ModuleVideoPromptRetryV2,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
project, new_step = await rebuild_video_prompt_step_v2(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
source_prompt_step_id=step_id,
|
||||||
|
video_config=req.video_config,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(new_step.id)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(project.user_id),
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_version=int(new_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
await _dispatch_or_mark_failed(
|
||||||
|
db,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
return HotOpeningActionOut(
|
||||||
|
message="视频提词已重新提交",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||||
|
response_model=HotOpeningActionOut,
|
||||||
|
)
|
||||||
|
async def update_video_prompt_schema_route_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
req: ModuleVideoPromptSchemaUpdateV2,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
project, step = await update_video_prompt_schema_v2(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
req=req,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(step.id)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
return HotOpeningActionOut(
|
||||||
|
message="视频提词已保存",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tasks/{project_id}/steps/{step_id}/generate-video",
|
||||||
|
response_model=HotOpeningActionOut,
|
||||||
|
)
|
||||||
|
async def generate_video_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
project, step, task = await generate_video_from_prompt_v2(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
prompt_step_id=step_id,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(step.id)
|
||||||
|
task_id = str(task.id)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# commit 后重新读取,避免 ORM expire/lazy-load 风险。
|
||||||
|
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||||
|
if queued_task is None:
|
||||||
|
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||||
|
try:
|
||||||
|
await enqueue_generation_create(queued_task, reason="hot_opening_v2_generate_video")
|
||||||
|
except Exception as exc:
|
||||||
|
# queued 状态已持久化,周期生成恢复任务会使用确定性 task_id 补投。
|
||||||
|
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||||
|
|
||||||
|
return HotOpeningActionOut(
|
||||||
|
message="视频生成任务已提交",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{project_id}", response_model=HotOpeningDeleteOut)
|
||||||
|
async def delete_project_route_v2(
|
||||||
|
project_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = await delete_project_v2(
|
||||||
|
db,
|
||||||
|
config=HOT_OPENING_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
if pending_ids:
|
||||||
|
try:
|
||||||
|
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
return HotOpeningDeleteOut(**payload)
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_current_user, get_db
|
||||||
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.module_generation_v2 import (
|
||||||
|
ModuleVideoPromptRetryV2,
|
||||||
|
ModuleVideoPromptSchemaUpdateV2,
|
||||||
|
ShotReplicateProjectCreateV2,
|
||||||
|
)
|
||||||
|
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
|
||||||
|
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||||
|
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
|
||||||
|
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
|
||||||
|
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
|
||||||
|
from app.services.module_generation_v2.dispatch_service import (
|
||||||
|
dispatch_video_prompt_v2,
|
||||||
|
ensure_v2_celery_enabled,
|
||||||
|
)
|
||||||
|
from app.services.module_generation_v2.flow_service import (
|
||||||
|
build_v2_video_prompt_billing_context,
|
||||||
|
create_shot_replicate_project_v2,
|
||||||
|
delete_project_v2,
|
||||||
|
generate_video_from_prompt_v2,
|
||||||
|
get_v2_project_for_user,
|
||||||
|
is_project_idempotency_conflict,
|
||||||
|
mark_video_prompt_dispatch_failed_v2,
|
||||||
|
rebuild_video_prompt_step_v2,
|
||||||
|
update_video_prompt_schema_v2,
|
||||||
|
)
|
||||||
|
from app.services.shot_replicate_flow_service import project_to_detail_out
|
||||||
|
from app.services.shot_replicate_taskset_service import get_segment_for_user
|
||||||
|
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
|
||||||
|
context = build_v2_video_prompt_billing_context(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=step_version,
|
||||||
|
module=SHOT_REPLICATE_V2.module,
|
||||||
|
display_name=SHOT_REPLICATE_V2.display_name,
|
||||||
|
)
|
||||||
|
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||||
|
project = await get_v2_project_for_user(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
return await project_to_detail_out(db, project)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_or_mark_failed(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
) -> None:
|
||||||
|
dispatch = await dispatch_video_prompt_v2(
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
if dispatch.recoverable:
|
||||||
|
return
|
||||||
|
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||||
|
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
|
||||||
|
return
|
||||||
|
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||||
|
await mark_video_prompt_dispatch_failed_v2(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
log_celery_dispatch_compensated(billing_context, error=error_message)
|
||||||
|
raise HTTPException(status_code=503, detail=error_message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/segments/{segment_id}/replication-projects",
|
||||||
|
response_model=ShotReplicateActionOut,
|
||||||
|
)
|
||||||
|
async def create_project_v2(
|
||||||
|
segment_id: str = Path(...),
|
||||||
|
req: ShotReplicateProjectCreateV2 = Body(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
segment = await get_segment_for_user(
|
||||||
|
db, segment_id=segment_id, user=current_user, for_update=True
|
||||||
|
)
|
||||||
|
result = await create_shot_replicate_project_v2(
|
||||||
|
db, current_user=current_user, segment=segment, req=req
|
||||||
|
)
|
||||||
|
project_id = str(result.project.id)
|
||||||
|
step_id = str(result.prompt_step.id)
|
||||||
|
created_new = bool(result.created_new)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(result.project.user_id),
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=int(result.prompt_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||||
|
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||||
|
segment = await get_segment_for_user(
|
||||||
|
db, segment_id=segment_id, user=current_user, for_update=True
|
||||||
|
)
|
||||||
|
result = await create_shot_replicate_project_v2(
|
||||||
|
db, current_user=current_user, segment=segment, req=req
|
||||||
|
)
|
||||||
|
project_id = str(result.project.id)
|
||||||
|
step_id = str(result.prompt_step.id)
|
||||||
|
created_new = bool(result.created_new)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(result.project.user_id),
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
step_version=int(result.prompt_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
|
||||||
|
|
||||||
|
if created_new:
|
||||||
|
await _dispatch_or_mark_failed(
|
||||||
|
db, project_id=project_id, step_id=step_id, billing_context=billing_context
|
||||||
|
)
|
||||||
|
return ShotReplicateActionOut(
|
||||||
|
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
detail=await _detail(db, current_user, project_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}", response_model=ShotReplicateTaskDetailOut)
|
||||||
|
async def get_project_v2(
|
||||||
|
project_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _detail(db, current_user, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||||
|
response_model=ShotReplicateActionOut,
|
||||||
|
)
|
||||||
|
async def retry_video_prompt_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
req: ModuleVideoPromptRetryV2,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
project, new_step = await rebuild_video_prompt_step_v2(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
source_prompt_step_id=step_id,
|
||||||
|
video_config=req.video_config,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(new_step.id)
|
||||||
|
billing_context = _dispatch_context(
|
||||||
|
user_id=str(project.user_id),
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
step_version=int(new_step.version or 1),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
await _dispatch_or_mark_failed(
|
||||||
|
db,
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
billing_context=billing_context,
|
||||||
|
)
|
||||||
|
return ShotReplicateActionOut(
|
||||||
|
message="视频提词已重新提交",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||||
|
response_model=ShotReplicateActionOut,
|
||||||
|
)
|
||||||
|
async def update_video_prompt_schema_route_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
req: ModuleVideoPromptSchemaUpdateV2,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
project, step = await update_video_prompt_schema_v2(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=step_id,
|
||||||
|
req=req,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(step.id)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
return ShotReplicateActionOut(
|
||||||
|
message="视频提词已保存",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/steps/{step_id}/generate-video",
|
||||||
|
response_model=ShotReplicateActionOut,
|
||||||
|
)
|
||||||
|
async def generate_video_v2(
|
||||||
|
project_id: str,
|
||||||
|
step_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_v2_celery_enabled()
|
||||||
|
try:
|
||||||
|
project, step, task = await generate_video_from_prompt_v2(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
prompt_step_id=step_id,
|
||||||
|
)
|
||||||
|
project_id_value = str(project.id)
|
||||||
|
step_id_value = str(step.id)
|
||||||
|
task_id = str(task.id)
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||||
|
if queued_task is None:
|
||||||
|
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||||
|
try:
|
||||||
|
await enqueue_generation_create(queued_task, reason="shot_replicate_v2_generate_video")
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||||
|
|
||||||
|
return ShotReplicateActionOut(
|
||||||
|
message="视频生成任务已提交",
|
||||||
|
project_id=project_id_value,
|
||||||
|
step_id=step_id_value,
|
||||||
|
detail=await _detail(db, current_user, project_id_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}", response_model=ShotReplicateDeleteOut)
|
||||||
|
async def delete_project_route_v2(
|
||||||
|
project_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = await delete_project_v2(
|
||||||
|
db,
|
||||||
|
config=SHOT_REPLICATE_V2,
|
||||||
|
current_user=current_user,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
if pending_ids:
|
||||||
|
try:
|
||||||
|
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
return ShotReplicateDeleteOut(**payload)
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
|
|
||||||
from app.enums.credit_record import (
|
|
||||||
CreditRecordAction,
|
|
||||||
CreditRecordChargeKind,
|
|
||||||
CreditRecordOwnerType,
|
|
||||||
CreditRecordType,
|
|
||||||
)
|
|
||||||
from app.enums.model_pricing import (
|
|
||||||
ModelPricingRuleStatus,
|
|
||||||
PricingSnapshotStage,
|
|
||||||
ProviderCostStatus,
|
|
||||||
)
|
|
||||||
from app.models.base import async_session
|
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
|
||||||
from app.models.credit_record import CreditRecord
|
|
||||||
from app.models.generated_resource import GeneratedResource
|
|
||||||
from app.models.generation_record import GenerationRecord
|
|
||||||
from app.models.image_engine import ImageEngine
|
|
||||||
from app.models.model_config import ModelConfig
|
|
||||||
from app.models.model_pricing_rule import ModelPricingRule
|
|
||||||
from app.models.module_generation_project import ModuleGenerationProject
|
|
||||||
from app.models.module_generation_step import ModuleGenerationStep
|
|
||||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
|
||||||
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
|
|
||||||
from app.models.token_usage import TokenUsage
|
|
||||||
from app.models.video_engine import VideoEngine
|
|
||||||
from app.services.model_pricing.attachment_snapshot_service import (
|
|
||||||
build_attachment_snapshot,
|
|
||||||
build_generation_snapshot,
|
|
||||||
)
|
|
||||||
from app.services.model_pricing.rule_service import normalize_provider
|
|
||||||
from app.services.model_pricing.snapshot_service import finalize_credit_record_pricing
|
|
||||||
from app.services.model_pricing.usage_normalizer import (
|
|
||||||
normalize_provider_media_usage,
|
|
||||||
parse_size,
|
|
||||||
safe_float,
|
|
||||||
safe_int,
|
|
||||||
safe_json_dict,
|
|
||||||
)
|
|
||||||
from app.services.operation_log_service import log_model_pricing_event
|
|
||||||
from app.services.resource_accounting_service import (
|
|
||||||
SOURCE_MODEL_CHAT_TASK,
|
|
||||||
SOURCE_MODEL_GENERATION_RECORD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PROCESSABLE_CHARGE_KINDS = {
|
|
||||||
CreditRecordChargeKind.MEDIA.value,
|
|
||||||
CreditRecordChargeKind.TEXT_PROMPT.value,
|
|
||||||
CreditRecordChargeKind.VIDEO_ANALYSIS.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
NON_PROVIDER_CHARGE_KINDS = {
|
|
||||||
CreditRecordChargeKind.FILE_PARSE.value,
|
|
||||||
CreditRecordChargeKind.VISION_INPUT.value,
|
|
||||||
CreditRecordChargeKind.MODULE_CREATE.value,
|
|
||||||
CreditRecordChargeKind.VIDEO_SPLIT.value,
|
|
||||||
CreditRecordChargeKind.RECHARGE.value,
|
|
||||||
CreditRecordChargeKind.REFUND.value,
|
|
||||||
CreditRecordChargeKind.ADMIN_ADJUST.value,
|
|
||||||
CreditRecordChargeKind.TEAM_INTERNAL.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
INCOMPLETE_COST_STATUSES = {
|
|
||||||
None,
|
|
||||||
"",
|
|
||||||
ProviderCostStatus.PENDING.value,
|
|
||||||
ProviderCostStatus.UNMATCHED_RULE.value,
|
|
||||||
ProviderCostStatus.USAGE_MISSING.value,
|
|
||||||
ProviderCostStatus.ERROR.value,
|
|
||||||
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value,
|
|
||||||
ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value,
|
|
||||||
ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BackfillContext:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.owner_maps: dict[str, dict[str, Any]] = {}
|
|
||||||
self.linked_chat_tasks: dict[str, ChatGenerationTask] = {}
|
|
||||||
self.token_usage_by_id: dict[str, TokenUsage] = {}
|
|
||||||
self.token_usage_by_owner: dict[tuple[str, str], TokenUsage] = {}
|
|
||||||
self.model_configs: dict[str, ModelConfig] = {}
|
|
||||||
self.image_engines: dict[str, ImageEngine] = {}
|
|
||||||
self.video_engines: dict[str, VideoEngine] = {}
|
|
||||||
self.resource_counts: dict[tuple[str, str], dict[str, int]] = {}
|
|
||||||
self.current_rules_by_category: dict[str, list[ModelPricingRule]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class BackfillStats:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.scanned = 0
|
|
||||||
self.changed = 0
|
|
||||||
self.calculated = 0
|
|
||||||
self.estimated = 0
|
|
||||||
self.rule_bound = 0
|
|
||||||
self.missing_engine = 0
|
|
||||||
self.missing_usage = 0
|
|
||||||
self.unmatched_rule = 0
|
|
||||||
self.not_applicable = 0
|
|
||||||
self.skipped_completed = 0
|
|
||||||
self.skipped_non_provider = 0
|
|
||||||
self.failed = 0
|
|
||||||
self.force_repriced = 0
|
|
||||||
|
|
||||||
def merge(self, other: "BackfillStats") -> None:
|
|
||||||
for key in vars(self):
|
|
||||||
setattr(self, key, getattr(self, key) + getattr(other, key))
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, int]:
|
|
||||||
return {key: int(value) for key, value in vars(self).items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: str | None, *, end: bool = False) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
parsed = datetime.fromisoformat(value)
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
if end and len(value) <= 10:
|
|
||||||
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_key(record: CreditRecord) -> tuple[str, str] | None:
|
|
||||||
owner_type = str(record.owner_type or "").strip()
|
|
||||||
owner_id = str(record.owner_id or record.related_id or "").strip()
|
|
||||||
return (owner_type, owner_id) if owner_type and owner_id else None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_provider_cost_candidate(record: CreditRecord) -> bool:
|
|
||||||
if record.type != CreditRecordType.CONSUME.value:
|
|
||||||
return False
|
|
||||||
if record.charge_action not in {None, "", CreditRecordAction.CHARGE.value}:
|
|
||||||
return False
|
|
||||||
charge_kind = str(record.charge_kind or "").strip()
|
|
||||||
if charge_kind in NON_PROVIDER_CHARGE_KINDS:
|
|
||||||
return False
|
|
||||||
if charge_kind in PROCESSABLE_CHARGE_KINDS:
|
|
||||||
return True
|
|
||||||
if str(record.media_type or "").lower() in {"image", "video"}:
|
|
||||||
return True
|
|
||||||
if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)):
|
|
||||||
return True
|
|
||||||
return bool(record.token_usage_id or _owner_key(record))
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_references(owner: Any) -> Any:
|
|
||||||
if owner is None:
|
|
||||||
return None
|
|
||||||
if isinstance(owner, ShotReplicateTaskSet):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"type": "video",
|
|
||||||
"url": owner.video_url,
|
|
||||||
"path": owner.video_path,
|
|
||||||
"duration_seconds": owner.video_duration_seconds,
|
|
||||||
"role": "reference_video",
|
|
||||||
"billable_input": True,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if isinstance(owner, ShotReplicateSegment):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"type": "video",
|
|
||||||
"url": owner.segment_video_url,
|
|
||||||
"path": owner.segment_video_path,
|
|
||||||
"duration_seconds": owner.duration_seconds,
|
|
||||||
"role": "reference_video",
|
|
||||||
"billable_input": True,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if hasattr(owner, "media_references"):
|
|
||||||
return getattr(owner, "media_references", None)
|
|
||||||
if hasattr(owner, "input_json"):
|
|
||||||
return getattr(owner, "input_json", None)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_response(owner: Any) -> Any:
|
|
||||||
if owner is None:
|
|
||||||
return None
|
|
||||||
for name in (
|
|
||||||
"provider_response_json",
|
|
||||||
"output_json",
|
|
||||||
"analysis_raw_json",
|
|
||||||
"analysis_result_json",
|
|
||||||
"analysis_json",
|
|
||||||
):
|
|
||||||
value = getattr(owner, name, None)
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_usage(value: Any) -> dict[str, Any]:
|
|
||||||
data = safe_json_dict(value)
|
|
||||||
if not data:
|
|
||||||
return {}
|
|
||||||
usage = data.get("usage")
|
|
||||||
if isinstance(usage, Mapping):
|
|
||||||
return deepcopy(dict(usage))
|
|
||||||
for key in ("result", "payload", "data", "response"):
|
|
||||||
child = data.get(key)
|
|
||||||
if isinstance(child, Mapping):
|
|
||||||
found = _nested_usage(child)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _select_token_usage(record: CreditRecord, owner: Any, ctx: BackfillContext) -> TokenUsage | None:
|
|
||||||
token_usage_id = (
|
|
||||||
record.token_usage_id
|
|
||||||
or getattr(owner, "token_usage_id", None)
|
|
||||||
)
|
|
||||||
if token_usage_id and token_usage_id in ctx.token_usage_by_id:
|
|
||||||
return ctx.token_usage_by_id[token_usage_id]
|
|
||||||
key = _owner_key(record)
|
|
||||||
if key and key in ctx.token_usage_by_owner:
|
|
||||||
return ctx.token_usage_by_owner[key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _usage_from_record(record: CreditRecord, owner: Any, token_usage: TokenUsage | None) -> dict[str, Any]:
|
|
||||||
usage = deepcopy(dict(record.usage_snapshot_json or {}))
|
|
||||||
payload_usage = _nested_usage(_provider_response(owner))
|
|
||||||
for key, value in payload_usage.items():
|
|
||||||
usage.setdefault(key, value)
|
|
||||||
|
|
||||||
owner_input = safe_int(getattr(owner, "input_tokens", None))
|
|
||||||
owner_output = safe_int(getattr(owner, "output_tokens", None))
|
|
||||||
owner_total = safe_int(getattr(owner, "total_tokens", None))
|
|
||||||
token_input = safe_int(getattr(token_usage, "input_tokens", None))
|
|
||||||
token_output = safe_int(getattr(token_usage, "output_tokens", None))
|
|
||||||
token_total = safe_int(getattr(token_usage, "total_tokens", None))
|
|
||||||
|
|
||||||
input_tokens = max(0, safe_int(record.input_tokens, token_input or owner_input))
|
|
||||||
output_tokens = max(0, safe_int(record.output_tokens, token_output or owner_output))
|
|
||||||
total_tokens = max(
|
|
||||||
0,
|
|
||||||
safe_int(record.total_tokens, token_total or owner_total or (input_tokens + output_tokens)),
|
|
||||||
)
|
|
||||||
if total_tokens <= 0:
|
|
||||||
total_tokens = input_tokens + output_tokens
|
|
||||||
if output_tokens <= 0 and total_tokens > input_tokens:
|
|
||||||
output_tokens = total_tokens - input_tokens
|
|
||||||
|
|
||||||
usage.update(
|
|
||||||
{
|
|
||||||
"input_tokens": input_tokens,
|
|
||||||
"output_tokens": output_tokens,
|
|
||||||
"total_tokens": total_tokens,
|
|
||||||
"context_tokens": max(0, safe_int(usage.get("context_tokens"), input_tokens)),
|
|
||||||
"usage_source": "backfill",
|
|
||||||
"provider_usage_primary": bool(record.provider_usage_primary),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return usage
|
|
||||||
|
|
||||||
|
|
||||||
def _get_engine_object(record: CreditRecord, owner: Any, ctx: BackfillContext) -> Any:
|
|
||||||
engine_id = str(record.engine_id or getattr(owner, "engine_id", None) or "").strip()
|
|
||||||
if not engine_id:
|
|
||||||
return None
|
|
||||||
media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower()
|
|
||||||
if media_type == "image":
|
|
||||||
return ctx.image_engines.get(engine_id)
|
|
||||||
if media_type == "video":
|
|
||||||
return ctx.video_engines.get(engine_id)
|
|
||||||
return ctx.image_engines.get(engine_id) or ctx.video_engines.get(engine_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_engine_snapshot(
|
|
||||||
record: CreditRecord,
|
|
||||||
*,
|
|
||||||
owner: Any,
|
|
||||||
linked_chat: ChatGenerationTask | None,
|
|
||||||
token_usage: TokenUsage | None,
|
|
||||||
ctx: BackfillContext,
|
|
||||||
) -> None:
|
|
||||||
response = safe_json_dict(_provider_response(linked_chat or owner))
|
|
||||||
owner_snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None))
|
|
||||||
chat_snapshot = safe_json_dict(getattr(linked_chat, "engine_snapshot_json", None))
|
|
||||||
engine = _get_engine_object(record, linked_chat or owner, ctx)
|
|
||||||
|
|
||||||
model_config_id = (
|
|
||||||
getattr(owner, "model_config_id", None)
|
|
||||||
or getattr(token_usage, "model_config_id", None)
|
|
||||||
or (record.engine_id if record.engine_type == "model" else None)
|
|
||||||
or response.get("model_config_id")
|
|
||||||
)
|
|
||||||
model_config = ctx.model_configs.get(str(model_config_id)) if model_config_id else None
|
|
||||||
|
|
||||||
model_name = (
|
|
||||||
record.engine_model_name
|
|
||||||
or response.get("model")
|
|
||||||
or response.get("model_name")
|
|
||||||
or chat_snapshot.get("model_name")
|
|
||||||
or chat_snapshot.get("engine_model_name")
|
|
||||||
or owner_snapshot.get("model_name")
|
|
||||||
or owner_snapshot.get("engine_model_name")
|
|
||||||
or getattr(engine, "model_name", None)
|
|
||||||
or getattr(model_config, "model_name", None)
|
|
||||||
)
|
|
||||||
provider = (
|
|
||||||
record.engine_provider
|
|
||||||
or chat_snapshot.get("provider")
|
|
||||||
or chat_snapshot.get("engine_provider")
|
|
||||||
or owner_snapshot.get("provider")
|
|
||||||
or owner_snapshot.get("engine_provider")
|
|
||||||
or getattr(engine, "provider", None)
|
|
||||||
or getattr(model_config, "provider", None)
|
|
||||||
)
|
|
||||||
if not provider and str(model_name or "").lower().startswith("doubao-"):
|
|
||||||
provider = "volcengine"
|
|
||||||
|
|
||||||
engine_id = (
|
|
||||||
record.engine_id
|
|
||||||
or getattr(linked_chat, "engine_id", None)
|
|
||||||
or getattr(owner, "engine_id", None)
|
|
||||||
or chat_snapshot.get("engine_id")
|
|
||||||
or chat_snapshot.get("id")
|
|
||||||
or owner_snapshot.get("engine_id")
|
|
||||||
or owner_snapshot.get("id")
|
|
||||||
or getattr(engine, "id", None)
|
|
||||||
or getattr(model_config, "id", None)
|
|
||||||
)
|
|
||||||
engine_name = (
|
|
||||||
record.engine_name
|
|
||||||
or chat_snapshot.get("engine_name")
|
|
||||||
or chat_snapshot.get("name")
|
|
||||||
or owner_snapshot.get("engine_name")
|
|
||||||
or owner_snapshot.get("name")
|
|
||||||
or getattr(engine, "name", None)
|
|
||||||
or getattr(model_config, "name", None)
|
|
||||||
)
|
|
||||||
|
|
||||||
record.engine_id = str(engine_id) if engine_id else None
|
|
||||||
record.engine_name = str(engine_name) if engine_name else None
|
|
||||||
record.engine_model_name = str(model_name) if model_name else None
|
|
||||||
record.engine_provider = (
|
|
||||||
normalize_provider(str(provider), record.engine_model_name)
|
|
||||||
if provider or record.engine_model_name
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if not record.engine_type:
|
|
||||||
if model_config is not None:
|
|
||||||
record.engine_type = "model"
|
|
||||||
else:
|
|
||||||
record.engine_type = str(record.media_type or getattr(linked_chat or owner, "gen_type", None) or "") or None
|
|
||||||
|
|
||||||
|
|
||||||
def _infer_category(record: CreditRecord, owner: Any) -> str | None:
|
|
||||||
charge_kind = str(record.charge_kind or "").strip()
|
|
||||||
if charge_kind in {CreditRecordChargeKind.TEXT_PROMPT.value, CreditRecordChargeKind.VIDEO_ANALYSIS.value}:
|
|
||||||
return "text"
|
|
||||||
media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower()
|
|
||||||
if media_type in {"image", "video"}:
|
|
||||||
return media_type
|
|
||||||
if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)):
|
|
||||||
return "text"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_unique_current_rule_fallback(record: CreditRecord, owner: Any, ctx: BackfillContext) -> None:
|
|
||||||
if record.engine_model_name:
|
|
||||||
return
|
|
||||||
category = _infer_category(record, owner)
|
|
||||||
rules = ctx.current_rules_by_category.get(category or "", [])
|
|
||||||
unique_models = {(rule.provider, rule.model_name) for rule in rules}
|
|
||||||
if len(unique_models) != 1:
|
|
||||||
return
|
|
||||||
provider, model_name = next(iter(unique_models))
|
|
||||||
record.engine_provider = provider
|
|
||||||
record.engine_model_name = model_name
|
|
||||||
record.engine_type = record.engine_type or ("model" if category == "text" else category)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_snapshot_fields(
|
|
||||||
record: CreditRecord,
|
|
||||||
*,
|
|
||||||
attachment_snapshot: dict[str, Any] | None,
|
|
||||||
attachment_counts: dict[str, Any] | None,
|
|
||||||
generation_snapshot: dict[str, Any] | None,
|
|
||||||
generation_counts: dict[str, Any] | None,
|
|
||||||
) -> None:
|
|
||||||
if attachment_snapshot is not None:
|
|
||||||
record.attachment_snapshot_json = deepcopy(attachment_snapshot)
|
|
||||||
for key, value in (attachment_counts or {}).items():
|
|
||||||
if hasattr(record, key):
|
|
||||||
setattr(record, key, value)
|
|
||||||
|
|
||||||
if generation_snapshot is not None:
|
|
||||||
record.generation_snapshot_json = deepcopy(generation_snapshot)
|
|
||||||
for key, value in (generation_counts or {}).items():
|
|
||||||
if hasattr(record, key):
|
|
||||||
setattr(record, key, value)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_resource_counts(
|
|
||||||
*,
|
|
||||||
record: CreditRecord,
|
|
||||||
generation_snapshot: dict[str, Any] | None,
|
|
||||||
generation_counts: dict[str, Any] | None,
|
|
||||||
resource_counts: dict[tuple[str, str], dict[str, int]],
|
|
||||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
||||||
source_model_by_owner_type = {
|
|
||||||
CreditRecordOwnerType.CHAT_GENERATION_TASK.value: SOURCE_MODEL_CHAT_TASK,
|
|
||||||
CreditRecordOwnerType.GENERATION_RECORD.value: SOURCE_MODEL_GENERATION_RECORD,
|
|
||||||
}
|
|
||||||
source_model = source_model_by_owner_type.get(record.owner_type or "")
|
|
||||||
bucket = (
|
|
||||||
resource_counts.get((source_model, record.owner_id))
|
|
||||||
if source_model and record.owner_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if not bucket:
|
|
||||||
return generation_snapshot, generation_counts
|
|
||||||
|
|
||||||
counts = deepcopy(dict(generation_counts or {}))
|
|
||||||
counts.update(
|
|
||||||
{
|
|
||||||
"generated_image_count": int(bucket["image"]),
|
|
||||||
"generated_video_count": int(bucket["video"]),
|
|
||||||
"generated_total_count": int(bucket["image"] + bucket["video"]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
snapshot = deepcopy(dict(generation_snapshot or {}))
|
|
||||||
snapshot["generated_image_count"] = counts["generated_image_count"]
|
|
||||||
snapshot["generated_video_count"] = counts["generated_video_count"]
|
|
||||||
snapshot["generated_total_count"] = counts["generated_total_count"]
|
|
||||||
snapshot["output_count_source"] = "generated_resource"
|
|
||||||
return snapshot, counts
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_image_output_items(
|
|
||||||
*,
|
|
||||||
usage: dict[str, Any],
|
|
||||||
owner: Any,
|
|
||||||
successful_count: int,
|
|
||||||
) -> None:
|
|
||||||
if successful_count <= 0:
|
|
||||||
return
|
|
||||||
items = usage.get("output_items")
|
|
||||||
if isinstance(items, list) and len(items) >= successful_count:
|
|
||||||
return
|
|
||||||
width, height = parse_size(getattr(owner, "image_px", None))
|
|
||||||
if width <= 0 or height <= 0:
|
|
||||||
return
|
|
||||||
usage["output_items"] = [
|
|
||||||
{
|
|
||||||
"index": index,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"pixels": width * height,
|
|
||||||
"size_source": "request_explicit_backfill",
|
|
||||||
}
|
|
||||||
for index in range(successful_count)
|
|
||||||
]
|
|
||||||
usage["output_pixels_are_estimated"] = True
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_context(
|
|
||||||
db,
|
|
||||||
records: list[CreditRecord],
|
|
||||||
*,
|
|
||||||
backfill_reference_at: datetime,
|
|
||||||
) -> BackfillContext:
|
|
||||||
ctx = BackfillContext()
|
|
||||||
ids_by_type: dict[str, set[str]] = {}
|
|
||||||
for record in records:
|
|
||||||
key = _owner_key(record)
|
|
||||||
if key:
|
|
||||||
ids_by_type.setdefault(key[0], set()).add(key[1])
|
|
||||||
if record.source_step_id:
|
|
||||||
ids_by_type.setdefault(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, set()).add(record.source_step_id)
|
|
||||||
|
|
||||||
model_by_owner = {
|
|
||||||
CreditRecordOwnerType.CHAT_GENERATION_TASK.value: ChatGenerationTask,
|
|
||||||
CreditRecordOwnerType.GENERATION_RECORD.value: GenerationRecord,
|
|
||||||
CreditRecordOwnerType.MODULE_GENERATION_PROJECT.value: ModuleGenerationProject,
|
|
||||||
CreditRecordOwnerType.MODULE_GENERATION_STEP.value: ModuleGenerationStep,
|
|
||||||
CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value: ShotReplicateTaskSet,
|
|
||||||
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value: ShotReplicateSegment,
|
|
||||||
}
|
|
||||||
for owner_type, model in model_by_owner.items():
|
|
||||||
ids = ids_by_type.get(owner_type) or set()
|
|
||||||
if not ids:
|
|
||||||
ctx.owner_maps[owner_type] = {}
|
|
||||||
continue
|
|
||||||
rows = (await db.execute(select(model).where(model.id.in_(ids)))).scalars().all()
|
|
||||||
ctx.owner_maps[owner_type] = {row.id: row for row in rows}
|
|
||||||
|
|
||||||
step_rows = list(ctx.owner_maps.get(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, {}).values())
|
|
||||||
linked_chat_ids = {str(step.chat_task_id) for step in step_rows if getattr(step, "chat_task_id", None)}
|
|
||||||
if linked_chat_ids:
|
|
||||||
rows = (
|
|
||||||
await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(linked_chat_ids)))
|
|
||||||
).scalars().all()
|
|
||||||
ctx.linked_chat_tasks = {row.id: row for row in rows}
|
|
||||||
|
|
||||||
token_usage_ids = {str(record.token_usage_id) for record in records if record.token_usage_id}
|
|
||||||
token_usage_ids.update(
|
|
||||||
str(step.token_usage_id)
|
|
||||||
for step in step_rows
|
|
||||||
if getattr(step, "token_usage_id", None)
|
|
||||||
)
|
|
||||||
owner_ids = {key[1] for record in records if (key := _owner_key(record))}
|
|
||||||
token_filters = []
|
|
||||||
if token_usage_ids:
|
|
||||||
token_filters.append(TokenUsage.id.in_(token_usage_ids))
|
|
||||||
if owner_ids:
|
|
||||||
token_filters.append(TokenUsage.owner_id.in_(owner_ids))
|
|
||||||
if token_filters:
|
|
||||||
token_rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(TokenUsage)
|
|
||||||
.where(or_(*token_filters))
|
|
||||||
.order_by(TokenUsage.created_at.desc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
for row in token_rows:
|
|
||||||
ctx.token_usage_by_id[row.id] = row
|
|
||||||
if row.owner_type and row.owner_id:
|
|
||||||
ctx.token_usage_by_owner.setdefault((row.owner_type, row.owner_id), row)
|
|
||||||
|
|
||||||
model_config_ids = {
|
|
||||||
str(row.model_config_id)
|
|
||||||
for row in ctx.token_usage_by_id.values()
|
|
||||||
if row.model_config_id
|
|
||||||
}
|
|
||||||
model_config_ids.update(
|
|
||||||
str(step.model_config_id)
|
|
||||||
for step in step_rows
|
|
||||||
if getattr(step, "model_config_id", None)
|
|
||||||
)
|
|
||||||
model_config_ids.update(
|
|
||||||
str(record.engine_id)
|
|
||||||
for record in records
|
|
||||||
if record.engine_type == "model" and record.engine_id
|
|
||||||
)
|
|
||||||
if model_config_ids:
|
|
||||||
rows = (
|
|
||||||
await db.execute(select(ModelConfig).where(ModelConfig.id.in_(model_config_ids)))
|
|
||||||
).scalars().all()
|
|
||||||
ctx.model_configs = {row.id: row for row in rows}
|
|
||||||
|
|
||||||
engine_ids = {str(record.engine_id) for record in records if record.engine_id}
|
|
||||||
for owner_map in ctx.owner_maps.values():
|
|
||||||
engine_ids.update(str(row.engine_id) for row in owner_map.values() if getattr(row, "engine_id", None))
|
|
||||||
engine_ids.update(str(row.engine_id) for row in ctx.linked_chat_tasks.values() if row.engine_id)
|
|
||||||
if engine_ids:
|
|
||||||
image_rows = (
|
|
||||||
await db.execute(select(ImageEngine).where(ImageEngine.id.in_(engine_ids)))
|
|
||||||
).scalars().all()
|
|
||||||
video_rows = (
|
|
||||||
await db.execute(select(VideoEngine).where(VideoEngine.id.in_(engine_ids)))
|
|
||||||
).scalars().all()
|
|
||||||
ctx.image_engines = {row.id: row for row in image_rows}
|
|
||||||
ctx.video_engines = {row.id: row for row in video_rows}
|
|
||||||
|
|
||||||
source_ids = {
|
|
||||||
record.owner_id
|
|
||||||
for record in records
|
|
||||||
if record.owner_id
|
|
||||||
and record.owner_type
|
|
||||||
in {
|
|
||||||
CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
|
|
||||||
CreditRecordOwnerType.GENERATION_RECORD.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if source_ids:
|
|
||||||
resources = (
|
|
||||||
await db.execute(
|
|
||||||
select(GeneratedResource)
|
|
||||||
.where(GeneratedResource.source_id.in_(source_ids))
|
|
||||||
.where(GeneratedResource.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
for resource in resources:
|
|
||||||
key = (str(resource.source_model or ""), resource.source_id)
|
|
||||||
bucket = ctx.resource_counts.setdefault(key, {"image": 0, "video": 0})
|
|
||||||
resource_type = str(resource.resource_type or "").lower()
|
|
||||||
if resource_type in bucket:
|
|
||||||
bucket[resource_type] += 1
|
|
||||||
|
|
||||||
current_rules = (
|
|
||||||
await db.execute(
|
|
||||||
select(ModelPricingRule)
|
|
||||||
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
|
|
||||||
.where(ModelPricingRule.effective_from <= backfill_reference_at)
|
|
||||||
.where(
|
|
||||||
or_(
|
|
||||||
ModelPricingRule.effective_to.is_(None),
|
|
||||||
ModelPricingRule.effective_to > backfill_reference_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(ModelPricingRule.model_category, ModelPricingRule.model_name)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
for rule in current_rules:
|
|
||||||
ctx.current_rules_by_category.setdefault(rule.model_category, []).append(rule)
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
def _record_state(record: CreditRecord) -> tuple[Any, ...]:
|
|
||||||
return (
|
|
||||||
record.engine_id,
|
|
||||||
record.engine_provider,
|
|
||||||
record.engine_model_name,
|
|
||||||
record.pricing_rule_id,
|
|
||||||
record.pricing_version_code,
|
|
||||||
record.provider_cost_status,
|
|
||||||
record.provider_cost_amount,
|
|
||||||
record.pricing_snapshot_hash,
|
|
||||||
record.attachment_total_count,
|
|
||||||
record.generated_total_count,
|
|
||||||
deepcopy(record.usage_snapshot_json),
|
|
||||||
deepcopy(record.attachment_snapshot_json),
|
|
||||||
deepcopy(record.generation_snapshot_json),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_result(record: CreditRecord, stats: BackfillStats, *, previous_rule_id: str | None, forced: bool) -> None:
|
|
||||||
status = record.provider_cost_status
|
|
||||||
if record.pricing_rule_id and record.pricing_rule_id != previous_rule_id:
|
|
||||||
stats.rule_bound += 1
|
|
||||||
if status == ProviderCostStatus.CALCULATED.value:
|
|
||||||
stats.calculated += 1
|
|
||||||
if forced:
|
|
||||||
stats.force_repriced += 1
|
|
||||||
elif status == ProviderCostStatus.ESTIMATED.value:
|
|
||||||
stats.estimated += 1
|
|
||||||
if forced:
|
|
||||||
stats.force_repriced += 1
|
|
||||||
elif status == ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value:
|
|
||||||
stats.missing_engine += 1
|
|
||||||
elif status == ProviderCostStatus.USAGE_MISSING.value:
|
|
||||||
stats.missing_usage += 1
|
|
||||||
elif status == ProviderCostStatus.UNMATCHED_RULE.value:
|
|
||||||
stats.unmatched_rule += 1
|
|
||||||
elif status == ProviderCostStatus.NOT_APPLICABLE.value:
|
|
||||||
stats.not_applicable += 1
|
|
||||||
|
|
||||||
|
|
||||||
async def run(args) -> None:
|
|
||||||
start_at = _parse_date(args.start_date)
|
|
||||||
end_at = _parse_date(args.end_date, end=True)
|
|
||||||
backfill_reference_at = _utcnow()
|
|
||||||
total = BackfillStats()
|
|
||||||
last_id: str | None = None
|
|
||||||
|
|
||||||
async with async_session() as db:
|
|
||||||
while True:
|
|
||||||
filters = [CreditRecord.type == CreditRecordType.CONSUME.value]
|
|
||||||
filters.append(
|
|
||||||
or_(
|
|
||||||
CreditRecord.charge_action == CreditRecordAction.CHARGE.value,
|
|
||||||
CreditRecord.charge_action.is_(None),
|
|
||||||
CreditRecord.charge_action == "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
filters.append(CreditRecord.created_at <= backfill_reference_at)
|
|
||||||
if last_id:
|
|
||||||
filters.append(CreditRecord.id > last_id)
|
|
||||||
if start_at:
|
|
||||||
filters.append(CreditRecord.created_at >= start_at)
|
|
||||||
if end_at:
|
|
||||||
filters.append(CreditRecord.created_at <= end_at)
|
|
||||||
if args.only_user_id:
|
|
||||||
filters.append(CreditRecord.user_id == args.only_user_id)
|
|
||||||
if args.only_owner_type:
|
|
||||||
filters.append(CreditRecord.owner_type == args.only_owner_type)
|
|
||||||
if args.only_missing and not args.force:
|
|
||||||
filters.append(
|
|
||||||
or_(
|
|
||||||
CreditRecord.pricing_rule_id.is_(None),
|
|
||||||
CreditRecord.provider_cost_status.is_(None),
|
|
||||||
CreditRecord.provider_cost_status.in_([value for value in INCOMPLETE_COST_STATUSES if value]),
|
|
||||||
CreditRecord.attachment_snapshot_json.is_(None),
|
|
||||||
CreditRecord.generation_snapshot_json.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
records = (
|
|
||||||
await db.execute(
|
|
||||||
select(CreditRecord)
|
|
||||||
.where(*filters)
|
|
||||||
.order_by(CreditRecord.id)
|
|
||||||
.limit(args.batch_size)
|
|
||||||
.with_for_update(skip_locked=True)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
if not records:
|
|
||||||
break
|
|
||||||
|
|
||||||
ctx = await _load_context(
|
|
||||||
db,
|
|
||||||
records,
|
|
||||||
backfill_reference_at=backfill_reference_at,
|
|
||||||
)
|
|
||||||
batch = BackfillStats()
|
|
||||||
|
|
||||||
for record in records:
|
|
||||||
batch.scanned += 1
|
|
||||||
record_id = str(record.id)
|
|
||||||
user_id = str(record.user_id) if record.user_id else None
|
|
||||||
owner_type = str(record.owner_type or "") or None
|
|
||||||
owner_id = str(record.owner_id or record.related_id or "") or None
|
|
||||||
|
|
||||||
if not _is_provider_cost_candidate(record):
|
|
||||||
batch.skipped_non_provider += 1
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
not args.force
|
|
||||||
and record.provider_cost_status == ProviderCostStatus.CALCULATED.value
|
|
||||||
and record.pricing_rule_id
|
|
||||||
and record.attachment_snapshot_json is not None
|
|
||||||
and record.generation_snapshot_json is not None
|
|
||||||
):
|
|
||||||
batch.skipped_completed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
before = _record_state(record)
|
|
||||||
previous_rule_id = record.pricing_rule_id
|
|
||||||
try:
|
|
||||||
async with db.begin_nested():
|
|
||||||
key = _owner_key(record)
|
|
||||||
owner = ctx.owner_maps.get(key[0], {}).get(key[1]) if key else None
|
|
||||||
if owner is None and record.source_step_id:
|
|
||||||
owner = ctx.owner_maps.get(
|
|
||||||
CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
|
|
||||||
{},
|
|
||||||
).get(record.source_step_id)
|
|
||||||
|
|
||||||
linked_chat = None
|
|
||||||
if isinstance(owner, ModuleGenerationStep) and owner.chat_task_id:
|
|
||||||
linked_chat = ctx.linked_chat_tasks.get(owner.chat_task_id)
|
|
||||||
media_owner = linked_chat or owner
|
|
||||||
token_usage = _select_token_usage(record, owner, ctx)
|
|
||||||
|
|
||||||
usage = _usage_from_record(record, owner, token_usage)
|
|
||||||
attachment_snapshot, attachment_counts = build_attachment_snapshot(
|
|
||||||
_raw_references(media_owner or owner)
|
|
||||||
)
|
|
||||||
generation_snapshot: dict[str, Any] | None = deepcopy(record.generation_snapshot_json)
|
|
||||||
generation_counts: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
gen_type = str(
|
|
||||||
record.media_type
|
|
||||||
or getattr(media_owner, "gen_type", None)
|
|
||||||
or ""
|
|
||||||
).lower()
|
|
||||||
response = _provider_response(media_owner or owner)
|
|
||||||
if gen_type in {"image", "video"} and media_owner is not None:
|
|
||||||
generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
|
|
||||||
media_owner,
|
|
||||||
provider_response=response,
|
|
||||||
stage=PricingSnapshotStage.BACKFILL.value,
|
|
||||||
)
|
|
||||||
provider_usage = normalize_provider_media_usage(
|
|
||||||
response,
|
|
||||||
gen_type=gen_type,
|
|
||||||
fallback_total_tokens=max(
|
|
||||||
safe_int(record.total_tokens),
|
|
||||||
safe_int(getattr(media_owner, "video_tokens_used", None)),
|
|
||||||
),
|
|
||||||
request_image_px=getattr(media_owner, "image_px", None),
|
|
||||||
requested_output_count=max(
|
|
||||||
1,
|
|
||||||
safe_int((generation_counts or {}).get("requested_output_count"), 1),
|
|
||||||
),
|
|
||||||
provider_input_image_count=safe_int(
|
|
||||||
attachment_counts.get("provider_input_image_count")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
usage.update(generation_usage)
|
|
||||||
usage.update(provider_usage)
|
|
||||||
usage.update(
|
|
||||||
{
|
|
||||||
"has_input_video": bool(
|
|
||||||
attachment_counts.get("provider_input_video_count")
|
|
||||||
),
|
|
||||||
"provider_input_image_count": safe_int(
|
|
||||||
provider_usage.get("provider_input_image_count"),
|
|
||||||
safe_int(attachment_counts.get("provider_input_image_count")),
|
|
||||||
),
|
|
||||||
"input_image_count": safe_int(
|
|
||||||
provider_usage.get("provider_input_image_count"),
|
|
||||||
safe_int(attachment_counts.get("provider_input_image_count")),
|
|
||||||
),
|
|
||||||
"input_video_duration_seconds": float(
|
|
||||||
attachment_counts.get("attachment_video_duration_seconds") or 0
|
|
||||||
),
|
|
||||||
"input_audio_duration_seconds": float(
|
|
||||||
attachment_counts.get("attachment_audio_duration_seconds") or 0
|
|
||||||
),
|
|
||||||
"usage_stage": PricingSnapshotStage.BACKFILL.value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
generation_snapshot, generation_counts = _merge_resource_counts(
|
|
||||||
record=record,
|
|
||||||
generation_snapshot=generation_snapshot,
|
|
||||||
generation_counts=generation_counts,
|
|
||||||
resource_counts=ctx.resource_counts,
|
|
||||||
)
|
|
||||||
if generation_counts:
|
|
||||||
successful_count = (
|
|
||||||
int(generation_counts.get("generated_image_count") or 0)
|
|
||||||
if gen_type == "image"
|
|
||||||
else int(generation_counts.get("generated_video_count") or 0)
|
|
||||||
)
|
|
||||||
usage["successful_output_count"] = successful_count
|
|
||||||
usage["generated_image_count"] = int(
|
|
||||||
generation_counts.get("generated_image_count") or 0
|
|
||||||
)
|
|
||||||
usage["generated_video_count"] = int(
|
|
||||||
generation_counts.get("generated_video_count") or 0
|
|
||||||
)
|
|
||||||
if gen_type == "image":
|
|
||||||
_ensure_image_output_items(
|
|
||||||
usage=usage,
|
|
||||||
owner=media_owner,
|
|
||||||
successful_count=successful_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
_restore_engine_snapshot(
|
|
||||||
record,
|
|
||||||
owner=owner,
|
|
||||||
linked_chat=linked_chat,
|
|
||||||
token_usage=token_usage,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
_apply_unique_current_rule_fallback(record, media_owner or owner, ctx)
|
|
||||||
|
|
||||||
if record.charge_action in {None, ""}:
|
|
||||||
record.charge_action = CreditRecordAction.CHARGE.value
|
|
||||||
|
|
||||||
_apply_snapshot_fields(
|
|
||||||
record,
|
|
||||||
attachment_snapshot=attachment_snapshot,
|
|
||||||
attachment_counts=attachment_counts,
|
|
||||||
generation_snapshot=generation_snapshot,
|
|
||||||
generation_counts=generation_counts,
|
|
||||||
)
|
|
||||||
|
|
||||||
await finalize_credit_record_pricing(
|
|
||||||
db,
|
|
||||||
charge=record,
|
|
||||||
usage=usage,
|
|
||||||
stage=PricingSnapshotStage.BACKFILL.value,
|
|
||||||
attachment_snapshot=attachment_snapshot,
|
|
||||||
attachment_counts=attachment_counts,
|
|
||||||
generation_snapshot=generation_snapshot,
|
|
||||||
generation_counts=generation_counts,
|
|
||||||
allow_upgrade_estimated=True,
|
|
||||||
pricing_reference_at=backfill_reference_at,
|
|
||||||
use_locked_rule=False,
|
|
||||||
force_reprice=bool(args.force),
|
|
||||||
backfill_metadata={
|
|
||||||
"is_backfilled": True,
|
|
||||||
"pricing_basis": "current_published_rule",
|
|
||||||
"backfill_reference_at": backfill_reference_at.isoformat(),
|
|
||||||
"original_credit_created_at": (
|
|
||||||
record.created_at.isoformat() if record.created_at else None
|
|
||||||
),
|
|
||||||
"command": "backfill_credit_record_snapshots",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
after = _record_state(record)
|
|
||||||
if before != after:
|
|
||||||
batch.changed += 1
|
|
||||||
_classify_result(
|
|
||||||
record,
|
|
||||||
batch,
|
|
||||||
previous_rule_id=previous_rule_id,
|
|
||||||
forced=bool(args.force),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
batch.failed += 1
|
|
||||||
log_model_pricing_event(
|
|
||||||
event_type="pricing_backfill_record_failed",
|
|
||||||
event_status="failed",
|
|
||||||
user_id=user_id,
|
|
||||||
credit_record_id=record_id,
|
|
||||||
owner_type=owner_type,
|
|
||||||
owner_id=owner_id,
|
|
||||||
error=str(exc),
|
|
||||||
detail={
|
|
||||||
"backfill_reference_at": backfill_reference_at.isoformat(),
|
|
||||||
"force": bool(args.force),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
last_id = str(records[-1].id)
|
|
||||||
total.merge(batch)
|
|
||||||
log_model_pricing_event(
|
|
||||||
event_type="pricing_backfill_batch",
|
|
||||||
event_status="success" if batch.failed == 0 else "warning",
|
|
||||||
detail={
|
|
||||||
**batch.as_dict(),
|
|
||||||
"batch_size": len(records),
|
|
||||||
"last_id": last_id,
|
|
||||||
"commit": bool(args.commit),
|
|
||||||
"force": bool(args.force),
|
|
||||||
"backfill_reference_at": backfill_reference_at.isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if args.commit:
|
|
||||||
await db.commit()
|
|
||||||
else:
|
|
||||||
await db.rollback()
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"batch={len(records)} scanned={batch.scanned} changed={batch.changed} "
|
|
||||||
f"bound={batch.rule_bound} calculated={batch.calculated} estimated={batch.estimated} "
|
|
||||||
f"missing_engine={batch.missing_engine} missing_usage={batch.missing_usage} "
|
|
||||||
f"unmatched_rule={batch.unmatched_rule} skipped_completed={batch.skipped_completed} "
|
|
||||||
f"skipped_non_provider={batch.skipped_non_provider} failed={batch.failed} last_id={last_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = "COMMIT" if args.commit else "DRY-RUN"
|
|
||||||
summary = " ".join(f"{key}={value}" for key, value in total.as_dict().items())
|
|
||||||
print(
|
|
||||||
f"{mode} DONE backfill_reference_at={backfill_reference_at.isoformat()} "
|
|
||||||
f"force={bool(args.force)} {summary}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"一次性将历史消费流水按执行时当前已发布的模型计价规则补齐:"
|
|
||||||
"同时恢复模型、附件/产出快照、绑定规则并计算供应商成本。"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument("--dry-run", action="store_true", help="执行完整计算但最终回滚")
|
|
||||||
group.add_argument("--commit", action="store_true", help="分批提交补录结果")
|
|
||||||
parser.add_argument("--batch-size", type=int, default=500)
|
|
||||||
parser.add_argument("--start-date")
|
|
||||||
parser.add_argument("--end-date")
|
|
||||||
parser.add_argument("--only-user-id")
|
|
||||||
parser.add_argument("--only-owner-type")
|
|
||||||
parser.add_argument(
|
|
||||||
"--only-missing",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="仅扫描规则/成本或附件/产出快照尚未完整的流水",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--force",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="按当前发布规则覆盖已经核算过的历史计价结果",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
args.batch_size = max(1, min(args.batch_size, 5000))
|
|
||||||
asyncio.run(run(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.enums.video_upscale import LOCAL_PROCESSOR_KEYS, VideoUpscaleTaskStatus
|
||||||
|
from app.models.base import async_session
|
||||||
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
|
from app.models.generation_record import GenerationRecord
|
||||||
|
from app.models.module_generation_step import ModuleGenerationStep
|
||||||
|
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||||
|
from app.models.video_upscale_task import VideoUpscaleTask
|
||||||
|
from app.services.video_upscale.media_service import is_valid_file
|
||||||
|
from app.services.video_upscale.task_service import reset_failed_upscale_task_for_manual_retry
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="人工恢复视频超分任务")
|
||||||
|
parser.add_argument("--task-id", action="append", default=[], help="ChatGenerationTask.id,可重复传入")
|
||||||
|
parser.add_argument("--task-ids", default="", help="逗号分隔的 ChatGenerationTask.id")
|
||||||
|
parser.add_argument("--generation-record-id", action="append", default=[], help="GenerationRecord.id,可重复传入")
|
||||||
|
parser.add_argument("--generation-record-ids", default="", help="逗号分隔的 GenerationRecord.id")
|
||||||
|
parser.add_argument("--project-id", action="append", default=[], help="Project.id,可重复传入")
|
||||||
|
parser.add_argument("--generation-mode", default="", help="按 ChatGenerationTask.generation_mode 筛选")
|
||||||
|
parser.add_argument("--module-owner-id", default="", help="ModuleGenerationProject.id")
|
||||||
|
parser.add_argument("--shot-task-set-id", default="", help="ShotReplicateTaskSet.id")
|
||||||
|
parser.add_argument("--shot-segment-id", action="append", default=[], help="ShotReplicateSegment.id,可重复传入")
|
||||||
|
parser.add_argument("--failed-only", action=argparse.BooleanOptionalAction, default=True)
|
||||||
|
parser.add_argument("--limit", type=int, default=100)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--enqueue", action=argparse.BooleanOptionalAction, default=True)
|
||||||
|
parser.add_argument("--force-resubmit", action="store_true", help="远程任务清空 provider task/result 后从 source.mp4 重新提交")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_chat_task_ids(db, args: argparse.Namespace) -> list[str]:
|
||||||
|
ids = [str(item).strip() for item in args.task_id if str(item).strip()]
|
||||||
|
ids.extend(item.strip() for item in str(args.task_ids or "").split(",") if item.strip())
|
||||||
|
project_ids: list[str] = [str(args.module_owner_id).strip()] if args.module_owner_id else []
|
||||||
|
|
||||||
|
segment_ids = [str(item).strip() for item in args.shot_segment_id if str(item).strip()]
|
||||||
|
if args.shot_task_set_id or segment_ids:
|
||||||
|
query = select(ShotReplicateSegment.module_project_id).where(
|
||||||
|
ShotReplicateSegment.deleted_at.is_(None),
|
||||||
|
ShotReplicateSegment.module_project_id.is_not(None),
|
||||||
|
)
|
||||||
|
if args.shot_task_set_id:
|
||||||
|
query = query.where(ShotReplicateSegment.task_set_id == str(args.shot_task_set_id).strip())
|
||||||
|
if segment_ids:
|
||||||
|
query = query.where(ShotReplicateSegment.id.in_(segment_ids))
|
||||||
|
result = await db.execute(query)
|
||||||
|
project_ids.extend(str(value) for value in result.scalars().all() if value)
|
||||||
|
|
||||||
|
project_ids = list(dict.fromkeys(item for item in project_ids if item))
|
||||||
|
if project_ids:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ModuleGenerationStep.chat_task_id).where(
|
||||||
|
ModuleGenerationStep.project_id.in_(project_ids),
|
||||||
|
ModuleGenerationStep.chat_task_id.isnot(None),
|
||||||
|
ModuleGenerationStep.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ids.extend(str(value) for value in result.scalars().all() if value)
|
||||||
|
return list(dict.fromkeys(ids))
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_generation_record_ids(db, args: argparse.Namespace) -> list[str]:
|
||||||
|
ids = [str(item).strip() for item in args.generation_record_id if str(item).strip()]
|
||||||
|
ids.extend(item.strip() for item in str(args.generation_record_ids or "").split(",") if item.strip())
|
||||||
|
project_ids = [str(item).strip() for item in args.project_id if str(item).strip()]
|
||||||
|
if project_ids:
|
||||||
|
result = await db.execute(
|
||||||
|
select(GenerationRecord.id).where(
|
||||||
|
GenerationRecord.project_id.in_(project_ids),
|
||||||
|
GenerationRecord.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ids.extend(str(value) for value in result.scalars().all() if value)
|
||||||
|
return list(dict.fromkeys(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_preview(upscale: VideoUpscaleTask, chat: ChatGenerationTask | None, record: GenerationRecord | None) -> dict:
|
||||||
|
owner = chat or record
|
||||||
|
return {
|
||||||
|
"upscale_task_id": upscale.id,
|
||||||
|
"owner_type": "chat_generation_task" if chat else "generation_record",
|
||||||
|
"owner_id": owner.id if owner else None,
|
||||||
|
"chat_task_id": chat.id if chat else None,
|
||||||
|
"generation_record_id": record.id if record else None,
|
||||||
|
"project_id": record.project_id if record else None,
|
||||||
|
"generation_mode": chat.generation_mode if chat else None,
|
||||||
|
"status": upscale.status,
|
||||||
|
"stage": upscale.stage,
|
||||||
|
"processor_key": upscale.processor_key,
|
||||||
|
"source_local_path": upscale.source_local_path,
|
||||||
|
"provider_task_id": upscale.provider_task_id,
|
||||||
|
"provider_output_url_expires_at": upscale.provider_output_url_expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(args: argparse.Namespace) -> dict:
|
||||||
|
async with async_session() as db:
|
||||||
|
chat_ids = await _collect_chat_task_ids(db, args)
|
||||||
|
record_ids = await _collect_generation_record_ids(db, args)
|
||||||
|
query = (
|
||||||
|
select(VideoUpscaleTask, ChatGenerationTask, GenerationRecord)
|
||||||
|
.outerjoin(ChatGenerationTask, ChatGenerationTask.id == VideoUpscaleTask.chat_generation_task_id)
|
||||||
|
.outerjoin(GenerationRecord, GenerationRecord.id == VideoUpscaleTask.generation_record_id)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
(ChatGenerationTask.id.isnot(None) & ChatGenerationTask.deleted_at.is_(None)),
|
||||||
|
(GenerationRecord.id.isnot(None) & GenerationRecord.deleted_at.is_(None)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(VideoUpscaleTask.updated_at.asc())
|
||||||
|
.limit(max(1, min(int(args.limit or 100), 1000)))
|
||||||
|
)
|
||||||
|
owner_filters = []
|
||||||
|
if chat_ids:
|
||||||
|
owner_filters.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
|
||||||
|
if record_ids:
|
||||||
|
owner_filters.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
|
||||||
|
if owner_filters:
|
||||||
|
query = query.where(or_(*owner_filters))
|
||||||
|
if args.generation_mode:
|
||||||
|
query = query.where(ChatGenerationTask.generation_mode == args.generation_mode)
|
||||||
|
if args.failed_only:
|
||||||
|
query = query.where(VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value)
|
||||||
|
result = await db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
preview = [_owner_preview(upscale, chat, record) for upscale, chat, record in rows]
|
||||||
|
if args.dry_run or not args.enqueue:
|
||||||
|
return {"dry_run": True, "matched": len(preview), "items": preview}
|
||||||
|
|
||||||
|
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
|
||||||
|
|
||||||
|
enqueued = []
|
||||||
|
for upscale, chat, record in rows:
|
||||||
|
reset = await reset_failed_upscale_task_for_manual_retry(
|
||||||
|
db,
|
||||||
|
upscale_task_id=upscale.id,
|
||||||
|
force_resubmit=bool(args.force_resubmit),
|
||||||
|
)
|
||||||
|
if is_valid_file(reset.final_local_path) and not args.force_resubmit:
|
||||||
|
action = "finalize"
|
||||||
|
finalize.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
|
||||||
|
elif reset.processor_key in LOCAL_PROCESSOR_KEYS:
|
||||||
|
action = "local"
|
||||||
|
execute_local.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
|
||||||
|
else:
|
||||||
|
expires_at = reset.provider_output_url_expires_at
|
||||||
|
if expires_at and expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() if expires_at else None
|
||||||
|
if reset.provider_output_url and remaining is not None and remaining >= 2 * 3600 and not args.force_resubmit:
|
||||||
|
action = "download"
|
||||||
|
download_remote_result.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
||||||
|
elif reset.provider_task_id and not args.force_resubmit:
|
||||||
|
action = "poll"
|
||||||
|
poll_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
||||||
|
else:
|
||||||
|
action = "submit"
|
||||||
|
submit_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
|
||||||
|
owner = chat or record
|
||||||
|
enqueued.append(
|
||||||
|
{
|
||||||
|
"upscale_task_id": reset.id,
|
||||||
|
"owner_type": "chat_generation_task" if chat else "generation_record",
|
||||||
|
"owner_id": owner.id if owner else None,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"dry_run": False, "matched": len(preview), "enqueued": enqueued}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _parser().parse_args()
|
||||||
|
result = asyncio.run(_run(args))
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.models.base import async_session
|
|
||||||
from app.models.menu_config import MenuConfig
|
|
||||||
from app.models.model_pricing_rule import ModelPricingRule
|
|
||||||
from app.services.model_pricing.rule_service import create_rule
|
|
||||||
from app.services.model_pricing.seed_data import volcengine_pricing_seed_rules
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_admin_menu(db) -> bool:
|
|
||||||
exists = (
|
|
||||||
await db.execute(
|
|
||||||
select(MenuConfig.id)
|
|
||||||
.where(MenuConfig.menu_target == "admin")
|
|
||||||
.where(MenuConfig.path == "/model-pricing")
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if exists:
|
|
||||||
return False
|
|
||||||
|
|
||||||
group_id = (
|
|
||||||
await db.execute(
|
|
||||||
select(MenuConfig.id)
|
|
||||||
.where(MenuConfig.menu_target == "admin")
|
|
||||||
.where(MenuConfig.menu_type == "group")
|
|
||||||
.where(MenuConfig.label == "模型设置")
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not group_id:
|
|
||||||
group_id = generate_id()
|
|
||||||
db.add(
|
|
||||||
MenuConfig(
|
|
||||||
id=group_id,
|
|
||||||
path="",
|
|
||||||
label="模型设置",
|
|
||||||
icon="RobotOutlined",
|
|
||||||
sort_order=98,
|
|
||||||
is_active=True,
|
|
||||||
menu_type="group",
|
|
||||||
menu_target="admin",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
db.add(
|
|
||||||
MenuConfig(
|
|
||||||
id=generate_id(),
|
|
||||||
path="/model-pricing",
|
|
||||||
label="模型计价",
|
|
||||||
icon="DollarOutlined",
|
|
||||||
sort_order=4,
|
|
||||||
is_active=True,
|
|
||||||
menu_type="page",
|
|
||||||
menu_target="admin",
|
|
||||||
parent_id=group_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def run(*, commit: bool) -> None:
|
|
||||||
async with async_session() as db:
|
|
||||||
created = skipped = 0
|
|
||||||
menu_created = await _ensure_admin_menu(db)
|
|
||||||
for payload in volcengine_pricing_seed_rules():
|
|
||||||
exists = (
|
|
||||||
await db.execute(
|
|
||||||
select(ModelPricingRule.id)
|
|
||||||
.where(ModelPricingRule.provider == payload["provider"])
|
|
||||||
.where(ModelPricingRule.model_name == payload["model_name"])
|
|
||||||
.where(ModelPricingRule.version_code == payload["version_code"])
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if exists:
|
|
||||||
skipped += 1
|
|
||||||
print(f"SKIP {payload['model_name']} {payload['version_code']} id={exists}")
|
|
||||||
continue
|
|
||||||
draft_payload = dict(payload)
|
|
||||||
draft_payload.pop("publish_status", None)
|
|
||||||
snapshot = await create_rule(db, payload=draft_payload, operator_id=None)
|
|
||||||
created += 1
|
|
||||||
effective_from = snapshot["effective_from"]
|
|
||||||
print(
|
|
||||||
f"CREATE_DRAFT {snapshot['model_name']} {snapshot['version_code']} id={snapshot['id']} "
|
|
||||||
f"effective_from={effective_from.isoformat()}"
|
|
||||||
)
|
|
||||||
if commit:
|
|
||||||
await db.commit()
|
|
||||||
print(f"COMMIT created={created} skipped={skipped} menu_created={menu_created}")
|
|
||||||
else:
|
|
||||||
await db.rollback()
|
|
||||||
print(f"DRY-RUN created={created} skipped={skipped} menu_created={menu_created}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="初始化火山模型计价草稿(不会自动发布,需人工核价后在后台发布)")
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument("--dry-run", action="store_true")
|
|
||||||
group.add_argument("--commit", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
asyncio.run(run(commit=bool(args.commit)))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+100
-10
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
|||||||
LLM_API_KEY: str = ""
|
LLM_API_KEY: str = ""
|
||||||
LLM_MODEL: str = "gpt-4o"
|
LLM_MODEL: str = "gpt-4o"
|
||||||
LLM_MOCK: bool = True
|
LLM_MOCK: bool = True
|
||||||
|
LLM_MEDIA_AS_BASE64: bool = True
|
||||||
|
|
||||||
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
|
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
|
||||||
|
|
||||||
@@ -95,6 +96,33 @@ class Settings(BaseSettings):
|
|||||||
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
|
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
|
||||||
# - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。
|
# - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。
|
||||||
FFMPEG_BIN: str = ""
|
FFMPEG_BIN: str = ""
|
||||||
|
|
||||||
|
# 视频超分配置。
|
||||||
|
# 本地处理器始终复用 FFMPEG_BIN,不允许由管理后台覆盖可执行文件路径。
|
||||||
|
VOLC_API_KEY: str = ""
|
||||||
|
VOLC_MEDIAKIT_API_BASE: str = "https://mediakit.cn-beijing.volces.com"
|
||||||
|
VIDEO_UPSCALE_LOCAL_QUEUE: str = "gen_video_upscale_local"
|
||||||
|
VIDEO_UPSCALE_REMOTE_QUEUE: str = "gen_video_upscale_remote"
|
||||||
|
VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS: int = 3600
|
||||||
|
VIDEO_UPSCALE_MAX_ATTEMPTS: int = 3
|
||||||
|
VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS: int = 30
|
||||||
|
VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS: int = 7200
|
||||||
|
VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS: int = 30
|
||||||
|
VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS: int = 600
|
||||||
|
VIDEO_UPSCALE_REMOTE_URL_PROBE_THRESHOLD_SECONDS: int = 600
|
||||||
|
VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS: int = 3
|
||||||
|
VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS: int = 5
|
||||||
|
VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS: int = 7200
|
||||||
|
VIDEO_UPSCALE_TASK_LEASE_SECONDS: int = 30 * 60
|
||||||
|
VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS: int = 60
|
||||||
|
VIDEO_UPSCALE_RECOVERY_BATCH_SIZE: int = 50
|
||||||
|
VIDEO_UPSCALE_RECOVERY_LOCK_KEY: str = "vg:celery:video_upscale_recovery_lock"
|
||||||
|
VIDEO_UPSCALE_EXECUTION_LOCK_KEY_PREFIX: str = "vg:lock:upscale:execute"
|
||||||
|
VIDEO_UPSCALE_EXECUTION_LOCK_TTL_SECONDS: int = 30 * 60
|
||||||
|
VIDEO_UPSCALE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:video_upscale:active"
|
||||||
|
VIDEO_UPSCALE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:video_upscale:active_index"
|
||||||
|
VIDEO_UPSCALE_STAGE_HANDOFF_DELAY_SECONDS: int = 2
|
||||||
|
|
||||||
VIDEO_COVER_SEEK_TIME: str = "00:00:01"
|
VIDEO_COVER_SEEK_TIME: str = "00:00:01"
|
||||||
VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00"
|
VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00"
|
||||||
VIDEO_COVER_WIDTH: int = 720
|
VIDEO_COVER_WIDTH: int = 720
|
||||||
@@ -103,7 +131,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
CAPTCHA_ENABLED: bool = True
|
CAPTCHA_ENABLED: bool = True
|
||||||
|
|
||||||
BASE_URL: str = "http://ceshi.apiforeign.minzhong.cn"
|
BASE_URL: str = ""
|
||||||
|
|
||||||
CORS_ORIGINS: list[str] = ["*"]
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
|
|
||||||
@@ -120,13 +148,34 @@ class Settings(BaseSettings):
|
|||||||
CELERY_ASYNC_RUNNER_MODE: str = "single_loop"
|
CELERY_ASYNC_RUNNER_MODE: str = "single_loop"
|
||||||
CELERY_DB_USE_NULLPOOL: bool = False
|
CELERY_DB_USE_NULLPOOL: bool = False
|
||||||
CELERY_STARTUP_RECOVERY_ENABLED: bool = True
|
CELERY_STARTUP_RECOVERY_ENABLED: bool = True
|
||||||
|
CELERY_STARTUP_RECOVERY_DELAY_SECONDS: int = 30
|
||||||
|
CELERY_RUNTIME_STARTUP_BARRIER_KEY: str = "vg:celery:recovery:startup_barrier"
|
||||||
|
CELERY_RUNTIME_STARTUP_BARRIER_TTL_SECONDS: int = 120
|
||||||
|
CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY: str = "vg:celery:recovery:global"
|
||||||
|
# Celery Runtime V2 Worker identity。
|
||||||
|
# worker_instance_id 只由 worker_name + host_boot_id + 主进程启动 token 构成,
|
||||||
|
# 与 prefork 子进程 PID、threads 线程数和 --concurrency 完全解耦。
|
||||||
|
CELERY_RUNTIME_SCHEMA_VERSION: int = 2
|
||||||
|
CELERY_RUNTIME_WORKER_INSTANCE_PREFIX: str = "vg:celery:v2:worker_instance"
|
||||||
|
CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX: str = "vg:celery:v2:worker_name_instances"
|
||||||
|
CELERY_RUNTIME_WORKER_TASK_SET_PREFIX: str = "vg:celery:v2:worker_tasks"
|
||||||
|
CELERY_RUNTIME_LOCATION_HASH_KEY: str = "vg:celery:runtime:locations"
|
||||||
|
CELERY_RUNTIME_WORKER_HEARTBEAT_INTERVAL_SECONDS: int = 30
|
||||||
|
CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS: int = 90
|
||||||
|
CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS: int = 24 * 60 * 60
|
||||||
|
CELERY_RUNTIME_WORKER_STALE_GRACE_SECONDS: int = 120
|
||||||
|
CELERY_RUNTIME_WORKER_STALE_SCAN_INTERVAL_SECONDS: int = 120
|
||||||
|
CELERY_RUNTIME_WORKER_HEARTBEAT_FAILURE_LOG_THRESHOLD: int = 3
|
||||||
|
CELERY_RUNTIME_RECONCILE_INTERVAL_SECONDS: int = 300
|
||||||
|
CELERY_RUNTIME_GC_INTERVAL_SECONDS: int = 600
|
||||||
|
CELERY_RUNTIME_GC_BATCH_SIZE: int = 500
|
||||||
|
|
||||||
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180
|
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 600
|
||||||
CHATAPI_VIDEO_FPS: float = 0.5
|
CHATAPI_VIDEO_FPS: float = 0.5
|
||||||
CHATAPI_ASYNC_MAX_RETRIES: int = 3
|
CHATAPI_ASYNC_MAX_RETRIES: int = 3
|
||||||
CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30
|
CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30
|
||||||
CHATAPI_ASYNC_POLL_INTERVAL_SECONDS: int = 30
|
CHATAPI_ASYNC_POLL_INTERVAL_SECONDS: int = 30
|
||||||
CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES: int = 10
|
CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES: int = 30
|
||||||
# 视频异步生成不再使用 30 分钟最终超时;前 10 分钟高频轮询,之后降频,24 小时最后判定失败才退款。
|
# 视频异步生成不再使用 30 分钟最终超时;前 10 分钟高频轮询,之后降频,24 小时最后判定失败才退款。
|
||||||
CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS: int = 24
|
CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS: int = 24
|
||||||
CHATAPI_ASYNC_VIDEO_HIGH_FREQ_MINUTES: int = 10
|
CHATAPI_ASYNC_VIDEO_HIGH_FREQ_MINUTES: int = 10
|
||||||
@@ -160,18 +209,15 @@ class Settings(BaseSettings):
|
|||||||
DOWNLOAD_TASK_LEASE_SECONDS: int = 10 * 60
|
DOWNLOAD_TASK_LEASE_SECONDS: int = 10 * 60
|
||||||
DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
||||||
DOWNLOAD_RECOVERY_BATCH_SIZE: int = 20
|
DOWNLOAD_RECOVERY_BATCH_SIZE: int = 20
|
||||||
DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS: int = 3
|
|
||||||
# 下载恢复自循环:不依赖 Celery beat,不新增 worker;由 gen_result_download 队列周期扫描 DB/Redis。
|
|
||||||
DOWNLOAD_RECOVERY_LOOP_ENABLED: bool = False
|
|
||||||
DOWNLOAD_RECOVERY_INTERVAL_SECONDS: int = 60
|
DOWNLOAD_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||||
DOWNLOAD_RECOVERY_LOOP_LOCK_KEY: str = "vg:celery:download_recovery_loop_lock"
|
|
||||||
DOWNLOAD_RECOVERY_LOOP_LOCK_TTL_SECONDS: int = 55
|
|
||||||
DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS: int = 1
|
DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS: int = 1
|
||||||
DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS: bool = True
|
DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS: bool = True
|
||||||
DOWNLOAD_EVENT_VERBOSE_ENABLED: bool = True
|
DOWNLOAD_EVENT_VERBOSE_ENABLED: bool = True
|
||||||
MEDIA_TOKEN_SNAPSHOT_ENABLED: bool = True
|
MEDIA_TOKEN_SNAPSHOT_ENABLED: bool = True
|
||||||
DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active"
|
DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active"
|
||||||
DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index"
|
DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index"
|
||||||
|
GENERATION_CREATE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:generation_create:active"
|
||||||
|
GENERATION_CREATE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:generation_create:active_index"
|
||||||
|
|
||||||
# Celery 生成链路 / provider poll 容灾配置。
|
# Celery 生成链路 / provider poll 容灾配置。
|
||||||
# 说明:
|
# 说明:
|
||||||
@@ -180,6 +226,9 @@ class Settings(BaseSettings):
|
|||||||
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
|
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
|
||||||
GENERATION_RECOVERY_BATCH_SIZE: int = 20
|
GENERATION_RECOVERY_BATCH_SIZE: int = 20
|
||||||
GENERATION_RECOVERY_MAX_ROUNDS: int = 1
|
GENERATION_RECOVERY_MAX_ROUNDS: int = 1
|
||||||
|
GENERATION_CREATE_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||||
|
GENERATION_CREATE_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
||||||
|
MODULE_ASYNC_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||||
POLL_RECOVERY_BATCH_SIZE: int = 20
|
POLL_RECOVERY_BATCH_SIZE: int = 20
|
||||||
POLL_TASK_LEASE_SECONDS: int = 5 * 60
|
POLL_TASK_LEASE_SECONDS: int = 5 * 60
|
||||||
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
|
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
|
||||||
@@ -196,6 +245,7 @@ class Settings(BaseSettings):
|
|||||||
DOWNLOAD_RECOVERY_LOCK_KEY: str = "vg:celery:download_recovery_lock"
|
DOWNLOAD_RECOVERY_LOCK_KEY: str = "vg:celery:download_recovery_lock"
|
||||||
MODULE_ASYNC_RECOVERY_LOCK_KEY: str = "vg:celery:module_async_recovery_lock"
|
MODULE_ASYNC_RECOVERY_LOCK_KEY: str = "vg:celery:module_async_recovery_lock"
|
||||||
SHOT_SPLIT_RECOVERY_LOCK_KEY: str = "vg:celery:shot_split_recovery_lock"
|
SHOT_SPLIT_RECOVERY_LOCK_KEY: str = "vg:celery:shot_split_recovery_lock"
|
||||||
|
SHOT_ANALYSIS_RECOVERY_LOCK_KEY: str = "vg:celery:shot_analysis_recovery_lock"
|
||||||
|
|
||||||
# 视频到期轮询调度。
|
# 视频到期轮询调度。
|
||||||
# Celery Beat 每分钟投递轻量 dispatcher 到 gen_recovery;dispatcher 只扫描 next_poll_at 到期的视频任务。
|
# Celery Beat 每分钟投递轻量 dispatcher 到 gen_recovery;dispatcher 只扫描 next_poll_at 到期的视频任务。
|
||||||
@@ -205,6 +255,22 @@ class Settings(BaseSettings):
|
|||||||
POLL_DUE_DISPATCH_LOCK_KEY: str = "vg:celery:poll_due_dispatch_lock"
|
POLL_DUE_DISPATCH_LOCK_KEY: str = "vg:celery:poll_due_dispatch_lock"
|
||||||
POLL_DUE_DISPATCH_LOCK_TTL_SECONDS: int = 55
|
POLL_DUE_DISPATCH_LOCK_TTL_SECONDS: int = 55
|
||||||
|
|
||||||
|
# Redis execution locks. These locks are fail-closed: when Redis is
|
||||||
|
# unavailable, the current Celery task retries and does not fall back to an
|
||||||
|
# unlocked database-only execution path.
|
||||||
|
GENERATION_CREATE_LOCK_KEY_PREFIX: str = "vg:lock:generation:create"
|
||||||
|
GENERATION_POLL_LOCK_KEY_PREFIX: str = "vg:lock:generation:poll"
|
||||||
|
GENERATION_DOWNLOAD_LOCK_KEY_PREFIX: str = "vg:lock:generation:download"
|
||||||
|
GENERATION_CREATE_LOCK_TTL_SECONDS: int = 10 * 60
|
||||||
|
GENERATION_POLL_LOCK_TTL_SECONDS: int = 5 * 60
|
||||||
|
GENERATION_DOWNLOAD_LOCK_TTL_SECONDS: int = 10 * 60
|
||||||
|
REDIS_EXECUTION_LOCK_RENEW_INTERVAL_SECONDS: int = 30
|
||||||
|
|
||||||
|
# PostgreSQL short row-lock wait and whole ordinary download timeout.
|
||||||
|
# The lock timeout is transaction-local (SET LOCAL), not a global DB setting.
|
||||||
|
GENERATION_DB_LOCK_TIMEOUT_SECONDS: int = 5
|
||||||
|
GENERATION_DOWNLOAD_TOTAL_TIMEOUT_SECONDS: int = 8 * 60
|
||||||
|
|
||||||
# 模块异步任务容灾配置。
|
# 模块异步任务容灾配置。
|
||||||
# 覆盖 ModuleGenerationStep 提词任务、shot 原视频/片段分析、shot ffmpeg 切割 active 注册。
|
# 覆盖 ModuleGenerationStep 提词任务、shot 原视频/片段分析、shot ffmpeg 切割 active 注册。
|
||||||
# 恢复扫描走 CELERY_RECOVERY_QUEUE,真实业务任务回到原始队列。
|
# 恢复扫描走 CELERY_RECOVERY_QUEUE,真实业务任务回到原始队列。
|
||||||
@@ -231,8 +297,17 @@ class Settings(BaseSettings):
|
|||||||
# 拆镜复刻配置。
|
# 拆镜复刻配置。
|
||||||
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
|
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
|
||||||
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 3600
|
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 3600
|
||||||
|
SHOT_ANALYSIS_SOFT_TIME_LIMIT_SECONDS: int = 3720
|
||||||
|
SHOT_ANALYSIS_TIME_LIMIT_SECONDS: int = 3900
|
||||||
|
SHOT_ANALYSIS_QUEUE: str = "gen_shot_analysis"
|
||||||
|
SHOT_ANALYSIS_LOCK_TTL_SECONDS: int = 180
|
||||||
|
SHOT_ANALYSIS_LEASE_SECONDS: int = 180
|
||||||
|
SHOT_ANALYSIS_HEARTBEAT_INTERVAL_SECONDS: int = 30
|
||||||
|
SHOT_ANALYSIS_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:shot_analysis:active"
|
||||||
|
SHOT_ANALYSIS_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:shot_analysis:active_index"
|
||||||
|
SHOT_ANALYSIS_LOCK_KEY_PREFIX: str = "vg:lock:shot_analysis"
|
||||||
SHOT_ANALYSIS_TEMPERATURE: float = 0.1
|
SHOT_ANALYSIS_TEMPERATURE: float = 0.1
|
||||||
SHOT_ANALYSIS_MAX_TOKENS: int = 5000
|
SHOT_ANALYSIS_MAX_TOKENS: int = 10000
|
||||||
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
|
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
|
||||||
SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45
|
SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45
|
||||||
|
|
||||||
@@ -246,7 +321,10 @@ class Settings(BaseSettings):
|
|||||||
SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20
|
SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20
|
||||||
FFPROBE_BIN: str = ""
|
FFPROBE_BIN: str = ""
|
||||||
|
|
||||||
# 继续复用 gen_result_download 队列,但限制 ffmpeg 并发,避免拖慢 Chat 下载。
|
# 拆镜切片使用独立队列,避免 FFmpeg 占用用户结果下载 worker。
|
||||||
|
SHOT_SPLIT_QUEUE: str = "gen_shot_split"
|
||||||
|
SHOT_SPLIT_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:shot_split:active"
|
||||||
|
SHOT_SPLIT_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:shot_split:active_index"
|
||||||
SHOT_SPLIT_MAX_CONCURRENT: int = 1
|
SHOT_SPLIT_MAX_CONCURRENT: int = 1
|
||||||
SHOT_SPLIT_MAX_RETRY_COUNT: int = 3
|
SHOT_SPLIT_MAX_RETRY_COUNT: int = 3
|
||||||
SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30
|
SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30
|
||||||
@@ -256,6 +334,18 @@ class Settings(BaseSettings):
|
|||||||
SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock"
|
SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock"
|
||||||
SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore"
|
SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore"
|
||||||
|
|
||||||
|
# 私域素材 Celery Runtime。轮询与远程删除使用对象级原子锁,Redis 不可用时停止外部调用。
|
||||||
|
PRIVATE_PORTRAIT_POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:private_portrait_poll:active"
|
||||||
|
PRIVATE_PORTRAIT_POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:private_portrait_poll:active_index"
|
||||||
|
PRIVATE_PORTRAIT_POLL_LOCK_KEY_PREFIX: str = "vg:lock:private_portrait:poll"
|
||||||
|
PRIVATE_PORTRAIT_DELETE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:private_portrait_delete:active"
|
||||||
|
PRIVATE_PORTRAIT_DELETE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:private_portrait_delete:active_index"
|
||||||
|
PRIVATE_PORTRAIT_DELETE_LOCK_KEY_PREFIX: str = "vg:lock:private_portrait:delete"
|
||||||
|
PRIVATE_PORTRAIT_RUNTIME_LOCK_TTL_SECONDS: int = 180
|
||||||
|
PRIVATE_PORTRAIT_RUNTIME_HEARTBEAT_SECONDS: int = 30
|
||||||
|
PRIVATE_PORTRAIT_DISPATCH_LOCK_KEY: str = "vg:celery:private_portrait:dispatch_lock"
|
||||||
|
PRIVATE_PORTRAIT_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:private_portrait:delete_recovery_lock"
|
||||||
|
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
|
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
|
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
|
||||||
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
|
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
|
||||||
|
|||||||
@@ -98,10 +98,16 @@ async def get_optional_current_user(
|
|||||||
async def get_admin_user(
|
async def get_admin_user(
|
||||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||||
) -> User:
|
) -> User:
|
||||||
if not current_user.is_admin or current_user.user_type != "admin":
|
"""验证后台用户权限。
|
||||||
|
|
||||||
|
- user_type="admin" 的后台用户即可通过(含非管理员子账号)
|
||||||
|
- 前端通过 allowed_menus 控制非管理员子账号的菜单可见性
|
||||||
|
- 非后台用户: 403 拒绝
|
||||||
|
"""
|
||||||
|
if current_user.user_type != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="需要管理员权限",
|
detail="需要后台用户权限",
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
@@ -109,6 +115,7 @@ async def get_admin_user(
|
|||||||
async def get_backend_user(
|
async def get_backend_user(
|
||||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""与 get_admin_user 等价: 验证 user_type="admin" 的后台用户。"""
|
||||||
if current_user.user_type != "admin":
|
if current_user.user_type != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ from app.enums.celery_queue import *
|
|||||||
from app.enums.audio_reference import *
|
from app.enums.audio_reference import *
|
||||||
|
|
||||||
from app.enums.private_portrait import *
|
from app.enums.private_portrait import *
|
||||||
|
from app.enums.generation_provider import *
|
||||||
|
|
||||||
|
from app.enums.generation_record import *
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ class CeleryQueue(str, Enum):
|
|||||||
GEN_CHATAPI_CREATE = "gen_chatapi_create"
|
GEN_CHATAPI_CREATE = "gen_chatapi_create"
|
||||||
GEN_PROVIDER_POLL = "gen_provider_poll"
|
GEN_PROVIDER_POLL = "gen_provider_poll"
|
||||||
GEN_RESULT_DOWNLOAD = "gen_result_download"
|
GEN_RESULT_DOWNLOAD = "gen_result_download"
|
||||||
|
GEN_VIDEO_UPSCALE_LOCAL = "gen_video_upscale_local"
|
||||||
|
GEN_VIDEO_UPSCALE_REMOTE = "gen_video_upscale_remote"
|
||||||
GEN_RECOVERY = "gen_recovery"
|
GEN_RECOVERY = "gen_recovery"
|
||||||
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
|
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
|
||||||
|
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
|
||||||
|
GEN_SHOT_SPLIT = "gen_shot_split"
|
||||||
DEFAULT = "default"
|
DEFAULT = "default"
|
||||||
|
|
||||||
|
|
||||||
@@ -16,12 +20,26 @@ class CeleryTaskName(str, Enum):
|
|||||||
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
|
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
|
||||||
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
|
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
|
||||||
RECOVER_GENERATION = "generation.recover_generation_tasks_once"
|
RECOVER_GENERATION = "generation.recover_generation_tasks_once"
|
||||||
|
RECOVER_CREATE = "generation.recover_create_tasks_once"
|
||||||
|
VIDEO_UPSCALE_EXECUTE_LOCAL = "video_upscale.execute_local"
|
||||||
|
VIDEO_UPSCALE_SUBMIT_REMOTE = "video_upscale.submit_remote"
|
||||||
|
VIDEO_UPSCALE_POLL_REMOTE = "video_upscale.poll_remote"
|
||||||
|
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
|
||||||
|
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
|
||||||
|
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
|
||||||
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
|
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
|
||||||
STARTUP_RECOVERY = "recovery.startup_recovery_once"
|
STARTUP_RECOVERY = "recovery.startup_recovery_once"
|
||||||
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
|
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
|
||||||
SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once"
|
SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once"
|
||||||
|
SHOT_ANALYSIS_RECOVERY = "shot_replicate.recover_analysis_tasks_once"
|
||||||
|
CELERY_RUNTIME_RECONCILE = "celery_runtime.reconcile_once"
|
||||||
|
CELERY_RUNTIME_GC = "celery_runtime.registry_gc_once"
|
||||||
|
SHOT_ANALYZE_ORIGINAL = "shot_replicate.analyze_original_video"
|
||||||
|
SHOT_ANALYZE_CUSTOM_SEGMENT = "shot_replicate.analyze_custom_segment_video"
|
||||||
|
SHOT_SPLIT_ONE = "shot_replicate.split_one_segment"
|
||||||
PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status"
|
PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status"
|
||||||
PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets"
|
PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets"
|
||||||
PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote"
|
PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote"
|
||||||
|
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
|
||||||
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
|
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
|
||||||
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
|
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryRuntimeDomain(StrEnum):
|
||||||
|
GENERATION_CREATE = "generation_create"
|
||||||
|
GENERATION_POLL = "generation_poll"
|
||||||
|
GENERATION_DOWNLOAD = "generation_download"
|
||||||
|
MODULE_ASYNC = "module_async"
|
||||||
|
SHOT_ANALYSIS = "shot_analysis"
|
||||||
|
SHOT_SPLIT = "shot_split"
|
||||||
|
VIDEO_UPSCALE = "video_upscale"
|
||||||
|
PRIVATE_PORTRAIT_POLL = "private_portrait_poll"
|
||||||
|
PRIVATE_PORTRAIT_DELETE = "private_portrait_delete"
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryRuntimeState(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
WAITING_LOCK_EXPIRE = "waiting_lock_expire"
|
||||||
|
WAITING_DB_LEASE = "waiting_db_lease"
|
||||||
|
RECOVERY_CANDIDATE = "recovery_candidate"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerIdentityQuality(StrEnum):
|
||||||
|
FULL = "full"
|
||||||
|
INSTANCE_TOKEN_ONLY = "instance_token_only"
|
||||||
|
SINGLE_PROCESS = "single_process"
|
||||||
|
FALLBACK = "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryRuntimeEvent(StrEnum):
|
||||||
|
WORKER_IDENTITY_INITIALIZED = "CELERY_WORKER_IDENTITY_INITIALIZED"
|
||||||
|
WORKER_IDENTITY_FALLBACK = "CELERY_WORKER_IDENTITY_FALLBACK"
|
||||||
|
WORKER_REGISTERED = "CELERY_WORKER_REGISTERED"
|
||||||
|
WORKER_HEARTBEAT_LOST = "CELERY_WORKER_HEARTBEAT_LOST"
|
||||||
|
WORKER_DUPLICATE_NAME = "CELERY_WORKER_DUPLICATE_NAME"
|
||||||
|
WORKER_STALE_INSTANCE_FOUND = "CELERY_WORKER_STALE_INSTANCE_FOUND"
|
||||||
|
WORKER_INSTANCE_RECOVERY_START = "CELERY_WORKER_INSTANCE_RECOVERY_START"
|
||||||
|
WORKER_INSTANCE_RECOVERY_DONE = "CELERY_WORKER_INSTANCE_RECOVERY_DONE"
|
||||||
|
WORKER_SHUTDOWN = "CELERY_WORKER_SHUTDOWN"
|
||||||
|
RUNTIME_ACQUIRE_START = "CELERY_RUNTIME_ACQUIRE_START"
|
||||||
|
RUNTIME_ACQUIRED = "CELERY_RUNTIME_ACQUIRED"
|
||||||
|
RUNTIME_LOCK_HELD = "CELERY_RUNTIME_LOCK_HELD"
|
||||||
|
RUNTIME_REDIS_UNAVAILABLE = "CELERY_RUNTIME_REDIS_UNAVAILABLE"
|
||||||
|
RUNTIME_HEARTBEAT_LOST = "CELERY_RUNTIME_HEARTBEAT_LOST"
|
||||||
|
RUNTIME_DB_LEASE_LOST = "CELERY_RUNTIME_DB_LEASE_LOST"
|
||||||
|
RUNTIME_RESULT_DISCARDED = "CELERY_RUNTIME_RESULT_DISCARDED"
|
||||||
|
RUNTIME_COMPLETED = "CELERY_RUNTIME_COMPLETED"
|
||||||
|
RECOVERY_BARRIER_SKIPPED = "CELERY_RECOVERY_BARRIER_SKIPPED"
|
||||||
|
RECOVERY_LIVE_LOCK_SKIPPED = "CELERY_RECOVERY_LIVE_LOCK_SKIPPED"
|
||||||
|
RECOVERY_WAIT_DB_LEASE = "CELERY_RECOVERY_WAIT_DB_LEASE"
|
||||||
|
RECOVERY_CAS_ACQUIRED = "CELERY_RECOVERY_CAS_ACQUIRED"
|
||||||
|
RECOVERY_REQUEUED = "CELERY_RECOVERY_REQUEUED"
|
||||||
|
REGISTRY_RECONCILE_DONE = "CELERY_REGISTRY_RECONCILE_DONE"
|
||||||
|
REGISTRY_GC_DONE = "CELERY_REGISTRY_GC_DONE"
|
||||||
@@ -26,6 +26,14 @@ class LogSourceEnum(StrEnum):
|
|||||||
CELERY = "celery"
|
CELERY = "celery"
|
||||||
RECOVERY = "recovery"
|
RECOVERY = "recovery"
|
||||||
REMOTE_API = "remote_api"
|
REMOTE_API = "remote_api"
|
||||||
|
CLI = "cli"
|
||||||
|
|
||||||
|
class ModuleGenerationFlowVersionEnum(StrEnum):
|
||||||
|
"""模块生成项目流程版本。"""
|
||||||
|
|
||||||
|
V1 = "v1"
|
||||||
|
V2 = "v2"
|
||||||
|
|
||||||
|
|
||||||
class ModuleProjectStatusEnum(StrEnum):
|
class ModuleProjectStatusEnum(StrEnum):
|
||||||
"""通用模块项目状态。"""
|
"""通用模块项目状态。"""
|
||||||
@@ -72,6 +80,19 @@ class ModuleEventTypeEnum(StrEnum):
|
|||||||
MEDIA_REFUND = "MEDIA_REFUND"
|
MEDIA_REFUND = "MEDIA_REFUND"
|
||||||
PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS"
|
PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS"
|
||||||
PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED"
|
PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED"
|
||||||
|
V1_CREATE_BLOCKED = "V1_CREATE_BLOCKED"
|
||||||
|
FLOW_VERSION_MISMATCH = "FLOW_VERSION_MISMATCH"
|
||||||
|
V2_PROJECT_CREATED = "V2_PROJECT_CREATED"
|
||||||
|
V2_VIDEO_PROMPT_AUTO_CREATED = "V2_VIDEO_PROMPT_AUTO_CREATED"
|
||||||
|
V2_VIDEO_PROMPT_REGENERATED = "V2_VIDEO_PROMPT_REGENERATED"
|
||||||
|
V2_VIDEO_PROMPT_DISPATCHED = "V2_VIDEO_PROMPT_DISPATCHED"
|
||||||
|
V2_VIDEO_PROMPT_REGISTRY_FAILED = "V2_VIDEO_PROMPT_REGISTRY_FAILED"
|
||||||
|
V2_VIDEO_PROMPT_DISPATCH_FAILED = "V2_VIDEO_PROMPT_DISPATCH_FAILED"
|
||||||
|
STEP_SUPERSEDED = "STEP_SUPERSEDED"
|
||||||
|
STALE_STEP_RESULT_DISCARDED = "STALE_STEP_RESULT_DISCARDED"
|
||||||
|
GENERATION_REFERENCE_OPTION_SAVED = "GENERATION_REFERENCE_OPTION_SAVED"
|
||||||
|
GENERATION_REFERENCE_INCLUDED = "GENERATION_REFERENCE_INCLUDED"
|
||||||
|
GENERATION_REFERENCE_EXCLUDED = "GENERATION_REFERENCE_EXCLUDED"
|
||||||
|
|
||||||
|
|
||||||
class ModulePromptTypeEnum(StrEnum):
|
class ModulePromptTypeEnum(StrEnum):
|
||||||
@@ -100,3 +121,15 @@ VIDEO_SCHEMA_MAX_SECTION_COUNT = 40
|
|||||||
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80
|
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80
|
||||||
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30
|
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30
|
||||||
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12
|
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12
|
||||||
|
|
||||||
|
|
||||||
|
MIN_GENERATION_COUNT = 1
|
||||||
|
MAX_GENERATION_COUNT = 5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class BillingBlockEventEnum(StrEnum):
|
||||||
|
"""通用账务拦截日志事件。"""
|
||||||
|
|
||||||
|
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
|
||||||
|
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class CreditRecordMediaType(str, Enum):
|
|||||||
class CreditRecordAction(str, Enum):
|
class CreditRecordAction(str, Enum):
|
||||||
CHARGE = "charge"
|
CHARGE = "charge"
|
||||||
REFUND = "refund"
|
REFUND = "refund"
|
||||||
|
HOLD = "hold"
|
||||||
|
HOLD_RELEASE = "hold_release"
|
||||||
|
|
||||||
|
|
||||||
class CreditRecordSourceModule(str, Enum):
|
class CreditRecordSourceModule(str, Enum):
|
||||||
@@ -112,6 +114,14 @@ class CreditRecordBillingScene(str, Enum):
|
|||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CREDIT_RECORD_ACTION_LABELS = {
|
||||||
|
CreditRecordAction.CHARGE.value: "真实扣费",
|
||||||
|
CreditRecordAction.REFUND.value: "真实退款",
|
||||||
|
CreditRecordAction.HOLD.value: "预扣占用",
|
||||||
|
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
|
||||||
|
}
|
||||||
|
|
||||||
CREDIT_RECORD_TYPE_LABELS = {
|
CREDIT_RECORD_TYPE_LABELS = {
|
||||||
CreditRecordType.RECHARGE.value: "充值",
|
CreditRecordType.RECHARGE.value: "充值",
|
||||||
CreditRecordType.CONSUME.value: "消费",
|
CreditRecordType.CONSUME.value: "消费",
|
||||||
|
|||||||
@@ -45,17 +45,28 @@ GENERATION_HISTORY_MODULE_SOURCES: tuple[GenerationHistorySourceEnum, ...] = (
|
|||||||
"""需要回填 module_generation_projects/module_generation_steps 的模块来源集合。"""
|
"""需要回填 module_generation_projects/module_generation_steps 的模块来源集合。"""
|
||||||
|
|
||||||
|
|
||||||
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = {
|
GENERATION_HISTORY_SOURCE_TO_TASK_MODES: dict[GenerationHistorySourceEnum, tuple[GenerationMode, ...]] = {
|
||||||
GenerationHistorySourceEnum.CHAT_TASK: GenerationMode.CHATAPI_ASYNC,
|
GenerationHistorySourceEnum.CHAT_TASK: (
|
||||||
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
|
GenerationMode.CHATAPI_ASYNC,
|
||||||
GenerationHistorySourceEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
|
GenerationMode.CHATAPI_CHILD,
|
||||||
|
),
|
||||||
|
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
|
||||||
|
GenerationHistorySourceEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
|
||||||
}
|
}
|
||||||
"""history_source 到 ChatGenerationTask.generation_mode 的映射。"""
|
"""history_source 到 ChatGenerationTask.generation_mode 集合的映射。"""
|
||||||
|
|
||||||
|
|
||||||
|
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = {
|
||||||
|
history_source: task_modes[0]
|
||||||
|
for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
|
||||||
|
}
|
||||||
|
"""兼容旧调用的单一模式映射;新查询应使用 GENERATION_HISTORY_SOURCE_TO_TASK_MODES。"""
|
||||||
|
|
||||||
|
|
||||||
GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = {
|
GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = {
|
||||||
task_mode.value: history_source
|
task_mode.value: history_source
|
||||||
for history_source, task_mode in GENERATION_HISTORY_SOURCE_TO_TASK_MODE.items()
|
for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
|
||||||
|
for task_mode in task_modes
|
||||||
}
|
}
|
||||||
"""ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。"""
|
"""ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。"""
|
||||||
|
|
||||||
@@ -103,11 +114,17 @@ def get_generation_history_source_label(source: GenerationHistorySourceEnum | st
|
|||||||
|
|
||||||
|
|
||||||
def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None:
|
def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None:
|
||||||
"""获取 history_source 对应的 ChatGenerationTask.generation_mode。"""
|
"""兼容旧调用:返回 history_source 对应的第一个任务模式。"""
|
||||||
|
|
||||||
return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source)
|
return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source)
|
||||||
|
|
||||||
|
|
||||||
|
def get_generation_history_task_modes(source: GenerationHistorySourceEnum) -> tuple[GenerationMode, ...]:
|
||||||
|
"""获取 history_source 对应的全部 ChatGenerationTask.generation_mode。"""
|
||||||
|
|
||||||
|
return GENERATION_HISTORY_SOURCE_TO_TASK_MODES.get(source, ())
|
||||||
|
|
||||||
|
|
||||||
def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool:
|
def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool:
|
||||||
"""判断当前来源是否走 chat_generation_tasks 表。"""
|
"""判断当前来源是否走 chat_generation_tasks 表。"""
|
||||||
|
|
||||||
@@ -121,3 +138,7 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
|
|||||||
|
|
||||||
|
|
||||||
MAX_BATCH_DELETE_COUNT = 30
|
MAX_BATCH_DELETE_COUNT = 30
|
||||||
|
|
||||||
|
|
||||||
|
HISTORY_DAY_PAGE_SIZE_MAX = 10
|
||||||
|
HISTORY_GROUP_ITEM_LIMIT = 10
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationProviderResultType(StrEnum):
|
||||||
|
IMAGE = "image"
|
||||||
|
VIDEO = "video"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationProviderTaskPhase(StrEnum):
|
||||||
|
SUBMITTED = "submitted"
|
||||||
|
POLLING = "polling"
|
||||||
|
RESULT_READY = "result_ready"
|
||||||
|
DOWNLOAD_PENDING = "download_pending"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageProviderErrorType(StrEnum):
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
NETWORK = "network"
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
AUTH = "auth"
|
||||||
|
INVALID_REQUEST = "invalid_request"
|
||||||
|
CAPABILITY_MISMATCH = "capability_mismatch"
|
||||||
|
CONTENT_REJECTED = "content_rejected"
|
||||||
|
PROVIDER_INTERNAL = "provider_internal"
|
||||||
|
INVALID_RESPONSE = "invalid_response"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_MULTI_OUTPUT_MIN = 1
|
||||||
|
IMAGE_MULTI_OUTPUT_MAX = 15
|
||||||
|
IMAGE_MULTI_REFERENCE_MAX = 14
|
||||||
|
IMAGE_PROVIDER_CLAIM_LEASE_SECONDS = 10 * 60
|
||||||
|
|
||||||
|
MULTI_IMAGE_PROMPT_TEMPLATE = (
|
||||||
|
"请严格生成恰好{count}张内容相关但画面具有明显差异的图片。"
|
||||||
|
"每张图片必须作为独立图片分别输出,不要把多个画面拼接到同一张图片中,"
|
||||||
|
"不要生成九宫格、分镜图、组合图或包含多张子图的单张图片。"
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRecordConfigSourceEnum(StrEnum):
|
||||||
|
"""生成记录配置冻结来源。"""
|
||||||
|
|
||||||
|
PROMPT_OPTIMIZE = "prompt_optimize"
|
||||||
|
LEGACY_GENERATE_FALLBACK = "legacy_generate_fallback"
|
||||||
|
EXISTING_FROZEN_CONFIG = "existing_frozen_config"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRecordEventTypeEnum(StrEnum):
|
||||||
|
"""GenerationRecord 用户生成链路事件。"""
|
||||||
|
|
||||||
|
PROMPT_CONFIG_VALIDATE_START = "PROMPT_CONFIG_VALIDATE_START"
|
||||||
|
PROMPT_CONFIG_VALIDATE_SUCCESS = "PROMPT_CONFIG_VALIDATE_SUCCESS"
|
||||||
|
PROMPT_CONFIG_VALIDATE_FAILED = "PROMPT_CONFIG_VALIDATE_FAILED"
|
||||||
|
PROMPT_CONFIG_FREEZE_START = "PROMPT_CONFIG_FREEZE_START"
|
||||||
|
PROMPT_CONFIG_FREEZE_SUCCESS = "PROMPT_CONFIG_FREEZE_SUCCESS"
|
||||||
|
PROMPT_CONFIG_FREEZE_FAILED = "PROMPT_CONFIG_FREEZE_FAILED"
|
||||||
|
PROMPT_OPTIMIZE_PLACEHOLDER_CREATED = "PROMPT_OPTIMIZE_PLACEHOLDER_CREATED"
|
||||||
|
PROMPT_OPTIMIZE_IDEMPOTENCY_HIT = "PROMPT_OPTIMIZE_IDEMPOTENCY_HIT"
|
||||||
|
PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED = "PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED"
|
||||||
|
PROMPT_OPTIMIZE_SETTLEMENT_PENDING = "PROMPT_OPTIMIZE_SETTLEMENT_PENDING"
|
||||||
|
PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS = "PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS"
|
||||||
|
PROMPT_OPTIMIZE_FAILED_RELEASED = "PROMPT_OPTIMIZE_FAILED_RELEASED"
|
||||||
|
LEGACY_CONFIG_FALLBACK_START = "LEGACY_CONFIG_FALLBACK_START"
|
||||||
|
LEGACY_CONFIG_FALLBACK_SUCCESS = "LEGACY_CONFIG_FALLBACK_SUCCESS"
|
||||||
|
LEGACY_CONFIG_FALLBACK_FAILED = "LEGACY_CONFIG_FALLBACK_FAILED"
|
||||||
|
LEGACY_CONFIG_FALLBACK_SKIPPED = "LEGACY_CONFIG_FALLBACK_SKIPPED"
|
||||||
|
GENERATION_SUBMIT_START = "GENERATION_SUBMIT_START"
|
||||||
|
GENERATION_SUBMIT_CONFIG_READY = "GENERATION_SUBMIT_CONFIG_READY"
|
||||||
|
GENERATION_SUBMIT_BILLING_SUCCESS = "GENERATION_SUBMIT_BILLING_SUCCESS"
|
||||||
|
GENERATION_SUBMIT_ENQUEUE_SUCCESS = "GENERATION_SUBMIT_ENQUEUE_SUCCESS"
|
||||||
|
GENERATION_SUBMIT_FAILED = "GENERATION_SUBMIT_FAILED"
|
||||||
@@ -3,12 +3,41 @@ from enum import Enum
|
|||||||
|
|
||||||
class GenerationStatus(str, Enum):
|
class GenerationStatus(str, Enum):
|
||||||
"""生成状态。"""
|
"""生成状态。"""
|
||||||
|
optimizing = "optimizing"
|
||||||
|
settlement_pending = "settlement_pending"
|
||||||
prompt_optimized = "prompt_optimized"
|
prompt_optimized = "prompt_optimized"
|
||||||
generating = "generating"
|
generating = "generating"
|
||||||
completed = "completed"
|
completed = "completed"
|
||||||
failed = "failed"
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRecordPipelineStage(str, Enum):
|
||||||
|
"""GenerationRecord 图片/视频生成、下载与超分流水线阶段。"""
|
||||||
|
|
||||||
|
QUEUED = "queued"
|
||||||
|
PREPARING = "preparing"
|
||||||
|
CREATING_PROVIDER_TASK = "creating_provider_task"
|
||||||
|
PROVIDER_RESULT_STAGED = "provider_result_staged"
|
||||||
|
WAITING_REMOTE = "waiting_remote"
|
||||||
|
POLLING = "polling"
|
||||||
|
RESULT_READY = "result_ready"
|
||||||
|
DOWNLOAD_QUEUED = "download_queued"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
RETRY_WAITING = "retry_waiting"
|
||||||
|
RECOVERY_INCONSISTENT = "recovery_inconsistent"
|
||||||
|
UPSCALE_QUEUED = "upscale_queued"
|
||||||
|
UPSCALE_PROCESSING = "upscale_processing"
|
||||||
|
UPSCALE_POLLING = "upscale_polling"
|
||||||
|
UPSCALE_DOWNLOADING = "upscale_downloading"
|
||||||
|
UPSCALE_FINALIZING = "upscale_finalizing"
|
||||||
|
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
|
||||||
|
UPSCALE_FAILED = "upscale_failed"
|
||||||
|
DONE = "done"
|
||||||
|
FAILED = "failed"
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
DOWNLOAD_FAILED = "download_failed"
|
||||||
|
|
||||||
|
|
||||||
class GenerationType(str, Enum):
|
class GenerationType(str, Enum):
|
||||||
"""生成类型。"""
|
"""生成类型。"""
|
||||||
video = "video"
|
video = "video"
|
||||||
@@ -19,4 +48,4 @@ class GenerationType(str, Enum):
|
|||||||
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
||||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||||
IMAGE_SIZES = ["2K", "4K"]
|
IMAGE_SIZES = ["1K", "2K", "4K"]
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationOwnerType(str, Enum):
|
||||||
|
CHAT_GENERATION_TASK = "chat_generation_task"
|
||||||
|
GENERATION_RECORD = "generation_record"
|
||||||
|
|
||||||
|
|
||||||
class GenerationMode(str, Enum):
|
class GenerationMode(str, Enum):
|
||||||
CHATAPI_ASYNC = "chatapi_async"
|
CHATAPI_ASYNC = "chatapi_async"
|
||||||
|
CHATAPI_MAIN = "chatapi_main"
|
||||||
|
CHATAPI_CHILD = "chatapi_child"
|
||||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||||
SHOT_REPLICATE = "shot_replicate"
|
SHOT_REPLICATE = "shot_replicate"
|
||||||
|
GENERATION_RECORD = "generation_record"
|
||||||
|
|
||||||
|
|
||||||
class GenerationType(str, Enum):
|
class GenerationType(str, Enum):
|
||||||
@@ -19,16 +27,34 @@ class ChatGenerationTaskStatus(str, Enum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGenerationDisplayStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
GENERATING = "generating"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
DOWNLOAD_FAILED = "download_failed"
|
||||||
|
DELETED = "deleted"
|
||||||
|
|
||||||
|
|
||||||
class ChatGenerationPipelineStage(str, Enum):
|
class ChatGenerationPipelineStage(str, Enum):
|
||||||
QUEUED = "queued"
|
QUEUED = "queued"
|
||||||
PREPARING = "preparing"
|
PREPARING = "preparing"
|
||||||
CREATING_PROVIDER_TASK = "creating_provider_task"
|
CREATING_PROVIDER_TASK = "creating_provider_task"
|
||||||
|
PROVIDER_RESULT_STAGED = "provider_result_staged"
|
||||||
WAITING_REMOTE = "waiting_remote"
|
WAITING_REMOTE = "waiting_remote"
|
||||||
POLLING = "polling"
|
POLLING = "polling"
|
||||||
RESULT_READY = "result_ready"
|
RESULT_READY = "result_ready"
|
||||||
DOWNLOAD_QUEUED = "download_queued"
|
DOWNLOAD_QUEUED = "download_queued"
|
||||||
DOWNLOADING = "downloading"
|
DOWNLOADING = "downloading"
|
||||||
RETRY_WAITING = "retry_waiting"
|
RETRY_WAITING = "retry_waiting"
|
||||||
|
RECOVERY_INCONSISTENT = "recovery_inconsistent"
|
||||||
|
UPSCALE_QUEUED = "upscale_queued"
|
||||||
|
UPSCALE_PROCESSING = "upscale_processing"
|
||||||
|
UPSCALE_POLLING = "upscale_polling"
|
||||||
|
UPSCALE_DOWNLOADING = "upscale_downloading"
|
||||||
|
UPSCALE_FINALIZING = "upscale_finalizing"
|
||||||
|
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
|
||||||
|
UPSCALE_FAILED = "upscale_failed"
|
||||||
DONE = "done"
|
DONE = "done"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
TIMEOUT = "timeout"
|
TIMEOUT = "timeout"
|
||||||
@@ -36,6 +62,31 @@ class ChatGenerationPipelineStage(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ChatGenerationTaskEventType(str, Enum):
|
class ChatGenerationTaskEventType(str, Enum):
|
||||||
|
TASK_CREATED = "TASK_CREATED"
|
||||||
|
IDEMPOTENCY_HIT = "IDEMPOTENCY_HIT"
|
||||||
|
BATCH_CREATE_START = "BATCH_CREATE_START"
|
||||||
|
BATCH_MAIN_CREATED = "BATCH_MAIN_CREATED"
|
||||||
|
BATCH_CHILDREN_CREATED = "BATCH_CHILDREN_CREATED"
|
||||||
|
BATCH_BILLING_SUCCESS = "BATCH_BILLING_SUCCESS"
|
||||||
|
BATCH_COMMIT_SUCCESS = "BATCH_COMMIT_SUCCESS"
|
||||||
|
CHILD_ENQUEUE_START = "CHILD_ENQUEUE_START"
|
||||||
|
CHILD_ENQUEUE_SUCCESS = "CHILD_ENQUEUE_SUCCESS"
|
||||||
|
CHILD_ENQUEUE_FAILED = "CHILD_ENQUEUE_FAILED"
|
||||||
|
IMAGE_MAIN_CLAIM_ACQUIRED = "IMAGE_MAIN_CLAIM_ACQUIRED"
|
||||||
|
IMAGE_MAIN_CLAIM_REJECTED = "IMAGE_MAIN_CLAIM_REJECTED"
|
||||||
|
IMAGE_MAIN_CLAIM_EXPIRED = "IMAGE_MAIN_CLAIM_EXPIRED"
|
||||||
|
IMAGE_BATCH_PROVIDER_START = "IMAGE_BATCH_PROVIDER_START"
|
||||||
|
IMAGE_BATCH_PROVIDER_SUCCESS = "IMAGE_BATCH_PROVIDER_SUCCESS"
|
||||||
|
IMAGE_BATCH_PROVIDER_FAILED = "IMAGE_BATCH_PROVIDER_FAILED"
|
||||||
|
IMAGE_BATCH_SPLIT_START = "IMAGE_BATCH_SPLIT_START"
|
||||||
|
IMAGE_BATCH_SPLIT_SUCCESS = "IMAGE_BATCH_SPLIT_SUCCESS"
|
||||||
|
IMAGE_BATCH_SPLIT_FAILED = "IMAGE_BATCH_SPLIT_FAILED"
|
||||||
|
MAIN_STATUS_AGGREGATED = "MAIN_STATUS_AGGREGATED"
|
||||||
|
CHILD_RESOURCE_DELETE_START = "CHILD_RESOURCE_DELETE_START"
|
||||||
|
CHILD_RESOURCE_DELETE_SUCCESS = "CHILD_RESOURCE_DELETE_SUCCESS"
|
||||||
|
BATCH_GROUP_DELETE_SUCCESS = "BATCH_GROUP_DELETE_SUCCESS"
|
||||||
|
BATCH_RECOVERY_RECONCILED = "BATCH_RECOVERY_RECONCILED"
|
||||||
|
|
||||||
PROMPT_CONCAT_START = "PROMPT_CONCAT_START"
|
PROMPT_CONCAT_START = "PROMPT_CONCAT_START"
|
||||||
PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS"
|
PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS"
|
||||||
|
|
||||||
@@ -57,6 +108,7 @@ class ChatGenerationTaskEventType(str, Enum):
|
|||||||
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
|
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
|
||||||
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
|
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
|
||||||
GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT"
|
GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT"
|
||||||
|
GENERATION_RECOVERY_INCONSISTENT = "GENERATION_RECOVERY_INCONSISTENT"
|
||||||
|
|
||||||
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
|
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
|
||||||
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
|
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
|
||||||
@@ -72,6 +124,19 @@ class ChatGenerationTaskEventType(str, Enum):
|
|||||||
DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
|
DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
|
||||||
DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE"
|
DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE"
|
||||||
|
|
||||||
|
UPSCALE_SNAPSHOT_MATCHED = "UPSCALE_SNAPSHOT_MATCHED"
|
||||||
|
UPSCALE_SNAPSHOT_BYPASSED = "UPSCALE_SNAPSHOT_BYPASSED"
|
||||||
|
UPSCALE_SOURCE_READY = "UPSCALE_SOURCE_READY"
|
||||||
|
UPSCALE_ENQUEUE = "UPSCALE_ENQUEUE"
|
||||||
|
UPSCALE_START = "UPSCALE_START"
|
||||||
|
UPSCALE_REMOTE_SUBMIT = "UPSCALE_REMOTE_SUBMIT"
|
||||||
|
UPSCALE_REMOTE_POLL = "UPSCALE_REMOTE_POLL"
|
||||||
|
UPSCALE_REMOTE_RESULT_READY = "UPSCALE_REMOTE_RESULT_READY"
|
||||||
|
UPSCALE_RETRY_WAITING = "UPSCALE_RETRY_WAITING"
|
||||||
|
UPSCALE_SUCCESS = "UPSCALE_SUCCESS"
|
||||||
|
UPSCALE_FAILED = "UPSCALE_FAILED"
|
||||||
|
UPSCALE_RECOVERY_ENQUEUE = "UPSCALE_RECOVERY_ENQUEUE"
|
||||||
|
|
||||||
DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING"
|
DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING"
|
||||||
DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE"
|
DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE"
|
||||||
DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING"
|
DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING"
|
||||||
@@ -82,13 +147,31 @@ class ChatGenerationTaskEventType(str, Enum):
|
|||||||
DOWNLOAD_SKIP_RETRY_NOT_DUE = "DOWNLOAD_SKIP_RETRY_NOT_DUE"
|
DOWNLOAD_SKIP_RETRY_NOT_DUE = "DOWNLOAD_SKIP_RETRY_NOT_DUE"
|
||||||
DOWNLOAD_SKIP_FINAL_STATE = "DOWNLOAD_SKIP_FINAL_STATE"
|
DOWNLOAD_SKIP_FINAL_STATE = "DOWNLOAD_SKIP_FINAL_STATE"
|
||||||
DOWNLOAD_SKIP_DISABLED = "DOWNLOAD_SKIP_DISABLED"
|
DOWNLOAD_SKIP_DISABLED = "DOWNLOAD_SKIP_DISABLED"
|
||||||
|
STALE_ATTEMPT_MESSAGE_SKIPPED = "STALE_ATTEMPT_MESSAGE_SKIPPED"
|
||||||
|
GENERATION_RECORD_ENQUEUE_SUCCESS = "GENERATION_RECORD_ENQUEUE_SUCCESS"
|
||||||
|
GENERATION_RECORD_ENQUEUE_FAILED = "GENERATION_RECORD_ENQUEUE_FAILED"
|
||||||
|
|
||||||
TASK_TIMEOUT = "TASK_TIMEOUT"
|
TASK_TIMEOUT = "TASK_TIMEOUT"
|
||||||
TASK_FAILED = "TASK_FAILED"
|
TASK_FAILED = "TASK_FAILED"
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_GENERATION_MODES = {
|
CHAT_TOP_LEVEL_MODES = {
|
||||||
GenerationMode.CHATAPI_ASYNC.value,
|
GenerationMode.CHATAPI_ASYNC.value,
|
||||||
|
GenerationMode.CHATAPI_MAIN.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAT_RESOURCE_MODES = {
|
||||||
|
GenerationMode.CHATAPI_ASYNC.value,
|
||||||
|
GenerationMode.CHATAPI_CHILD.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAT_EXECUTABLE_MODES = {
|
||||||
|
GenerationMode.CHATAPI_ASYNC.value,
|
||||||
|
GenerationMode.CHATAPI_CHILD.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_GENERATION_MODES = {
|
||||||
|
*CHAT_EXECUTABLE_MODES,
|
||||||
GenerationMode.HOT_OPENING_REPLICATE.value,
|
GenerationMode.HOT_OPENING_REPLICATE.value,
|
||||||
GenerationMode.SHOT_REPLICATE.value,
|
GenerationMode.SHOT_REPLICATE.value,
|
||||||
}
|
}
|
||||||
@@ -98,6 +181,7 @@ FINAL_CHAT_GENERATION_STAGES = {
|
|||||||
ChatGenerationPipelineStage.FAILED.value,
|
ChatGenerationPipelineStage.FAILED.value,
|
||||||
ChatGenerationPipelineStage.TIMEOUT.value,
|
ChatGenerationPipelineStage.TIMEOUT.value,
|
||||||
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
||||||
|
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_RECOVERABLE_STAGES = {
|
DOWNLOAD_RECOVERABLE_STAGES = {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class HotOpeningStepIOSchemaVersionEnum(StrEnum):
|
|||||||
"""爆款开头复刻子任务 input_json/output_json 结构版本。"""
|
"""爆款开头复刻子任务 input_json/output_json 结构版本。"""
|
||||||
|
|
||||||
V1 = "hot_opening_step_io_v1"
|
V1 = "hot_opening_step_io_v1"
|
||||||
|
V2 = "hot_opening_step_io_v2"
|
||||||
|
|
||||||
|
|
||||||
class HotOpeningLogEventEnum(StrEnum):
|
class HotOpeningLogEventEnum(StrEnum):
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class LlmBillingConfigKey(StrEnum):
|
||||||
|
ENABLED = "llm_billing_enabled"
|
||||||
|
HOLD_DEFAULT = "llm_hold_credits_default"
|
||||||
|
HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt"
|
||||||
|
HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt"
|
||||||
|
HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt"
|
||||||
|
HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis"
|
||||||
|
LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits"
|
||||||
|
|
||||||
|
|
||||||
|
class LlmBillingLedgerState(StrEnum):
|
||||||
|
BILLING_BYPASSED = "billing_bypassed"
|
||||||
|
MISSING = "missing"
|
||||||
|
ACTIVE = "active"
|
||||||
|
RELEASED = "released"
|
||||||
|
CHARGED = "charged"
|
||||||
|
INVALID = "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class LlmBillingEvent(StrEnum):
|
||||||
|
HOLD_START = "LLM_HOLD_START"
|
||||||
|
HOLD_SUCCESS = "LLM_HOLD_SUCCESS"
|
||||||
|
HOLD_BYPASSED = "LLM_HOLD_BYPASSED"
|
||||||
|
HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT"
|
||||||
|
HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID"
|
||||||
|
HOLD_MISSING = "LLM_HOLD_MISSING"
|
||||||
|
HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START"
|
||||||
|
HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS"
|
||||||
|
HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED"
|
||||||
|
FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START"
|
||||||
|
FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS"
|
||||||
|
FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED"
|
||||||
|
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
|
||||||
|
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
|
||||||
|
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
|
||||||
|
PROVIDER_START = "LLM_PROVIDER_START"
|
||||||
|
PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS"
|
||||||
|
PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE"
|
||||||
|
SETTLE_START = "LLM_SETTLE_START"
|
||||||
|
SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS"
|
||||||
|
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
|
||||||
|
CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE"
|
||||||
|
SETTLE_FAILED = "LLM_SETTLE_FAILED"
|
||||||
|
CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START"
|
||||||
|
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
|
||||||
|
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
|
||||||
|
CELERY_DISPATCH_COMPENSATED = "LLM_CELERY_DISPATCH_COMPENSATED"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
|
||||||
|
USAGE_INVALID = "LLM_USAGE_INVALID"
|
||||||
|
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
|
||||||
|
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
|
||||||
|
|
||||||
|
|
||||||
|
class LlmBillingDomain(StrEnum):
|
||||||
|
LLM_BILLING = "llm_billing"
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPricingProvider(str, Enum):
|
|
||||||
VOLCENGINE = "volcengine"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPricingCategory(str, Enum):
|
|
||||||
TEXT = "text"
|
|
||||||
IMAGE = "image"
|
|
||||||
VIDEO = "video"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPricingBillingMode(str, Enum):
|
|
||||||
TEXT_TOKEN_TIERED = "text_token_tiered"
|
|
||||||
IMAGE_PER_OUTPUT = "image_per_output"
|
|
||||||
IMAGE_INPUT_OUTPUT_TIERED = "image_input_output_tiered"
|
|
||||||
VIDEO_TOKEN_RATE = "video_token_rate"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPricingCalculatorVersion(str, Enum):
|
|
||||||
TEXT_TOKEN_TIERED_V1 = "text_token_tiered_v1"
|
|
||||||
IMAGE_PER_OUTPUT_V1 = "image_per_output_v1"
|
|
||||||
IMAGE_INPUT_OUTPUT_TIERED_V1 = "image_input_output_tiered_v1"
|
|
||||||
VIDEO_PIXEL_TOKEN_V1 = "video_pixel_token_v1"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPricingRuleStatus(str, Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
PUBLISHED = "published"
|
|
||||||
DISABLED = "disabled"
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderCostStatus(str, Enum):
|
|
||||||
NOT_APPLICABLE = "not_applicable"
|
|
||||||
NOT_INCURRED = "not_incurred"
|
|
||||||
PENDING = "pending"
|
|
||||||
CALCULATED = "calculated"
|
|
||||||
ESTIMATED = "estimated"
|
|
||||||
UNMATCHED_RULE = "unmatched_rule"
|
|
||||||
USAGE_MISSING = "usage_missing"
|
|
||||||
ERROR = "error"
|
|
||||||
PROVIDER_RESULT_UNCERTAIN = "provider_result_uncertain"
|
|
||||||
HISTORICAL_PRICE_UNAVAILABLE = "historical_price_unavailable"
|
|
||||||
HISTORICAL_ENGINE_UNAVAILABLE = "historical_engine_unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
class PricingUsageSource(str, Enum):
|
|
||||||
PROVIDER = "provider"
|
|
||||||
PROVIDER_RESPONSE = "provider_response"
|
|
||||||
REQUEST_FORMULA = "request_formula"
|
|
||||||
ENGINE_SNAPSHOT = "engine_snapshot"
|
|
||||||
PRICING_RULE_MAP = "pricing_rule_map"
|
|
||||||
BACKFILL = "backfill"
|
|
||||||
MANUAL = "manual"
|
|
||||||
UNAVAILABLE = "unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
class PricingSnapshotStage(str, Enum):
|
|
||||||
REQUEST_LOCKED = "request_locked"
|
|
||||||
PROVIDER_SYNC_COMPLETED = "provider_sync_completed"
|
|
||||||
PROVIDER_ASYNC_COMPLETED = "provider_async_completed"
|
|
||||||
RESOURCE_DOWNLOAD_COMPLETED = "resource_download_completed"
|
|
||||||
BACKFILL = "backfill"
|
|
||||||
|
|
||||||
|
|
||||||
class PricingDimensionSource(str, Enum):
|
|
||||||
PROVIDER_RESPONSE = "provider_response"
|
|
||||||
REQUEST_EXPLICIT = "request_explicit"
|
|
||||||
ENGINE_SNAPSHOT = "engine_snapshot"
|
|
||||||
PRICING_RULE_MAP = "pricing_rule_map"
|
|
||||||
UNAVAILABLE = "unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
class PricingBillBy(str, Enum):
|
|
||||||
SUCCESSFUL_OUTPUT_COUNT = "successful_output_count"
|
|
||||||
REQUESTED_OUTPUT_COUNT = "requested_output_count"
|
|
||||||
PROVIDER_BILLED_COUNT = "provider_billed_count"
|
|
||||||
|
|
||||||
|
|
||||||
class PricingInferenceMode(str, Enum):
|
|
||||||
ONLINE = "online"
|
|
||||||
FLEX = "flex"
|
|
||||||
BATCH = "batch"
|
|
||||||
|
|
||||||
|
|
||||||
MODEL_PRICING_BILLING_MODE_LABELS = {
|
|
||||||
ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: "文本分档 Token 计价",
|
|
||||||
ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: "图片按输出数量计价",
|
|
||||||
ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: "输入图片 + 输出像素分档计价",
|
|
||||||
ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: "视频像素 Token 计价",
|
|
||||||
}
|
|
||||||
|
|
||||||
MODEL_PRICING_RULE_STATUS_LABELS = {
|
|
||||||
ModelPricingRuleStatus.DRAFT.value: "草稿",
|
|
||||||
ModelPricingRuleStatus.PUBLISHED.value: "已发布",
|
|
||||||
ModelPricingRuleStatus.DISABLED.value: "已停用",
|
|
||||||
}
|
|
||||||
|
|
||||||
PROVIDER_COST_STATUS_LABELS = {
|
|
||||||
ProviderCostStatus.NOT_APPLICABLE.value: "不涉及供应商成本",
|
|
||||||
ProviderCostStatus.NOT_INCURRED.value: "供应商费用未发生",
|
|
||||||
ProviderCostStatus.PENDING.value: "待核算",
|
|
||||||
ProviderCostStatus.CALCULATED.value: "已核算",
|
|
||||||
ProviderCostStatus.ESTIMATED.value: "估算",
|
|
||||||
ProviderCostStatus.UNMATCHED_RULE.value: "未匹配价格",
|
|
||||||
ProviderCostStatus.USAGE_MISSING.value: "用量缺失",
|
|
||||||
ProviderCostStatus.ERROR.value: "核算异常",
|
|
||||||
ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value: "供应商结果不确定",
|
|
||||||
ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value: "历史价格缺失",
|
|
||||||
ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value: "历史引擎缺失",
|
|
||||||
}
|
|
||||||
@@ -24,3 +24,4 @@ class ModuleGenerationFlowConfig:
|
|||||||
cancel_chat_task_error_message: str
|
cancel_chat_task_error_message: str
|
||||||
material_video_url_editable: bool = True
|
material_video_url_editable: bool = True
|
||||||
step_io_schema_version: str = "module_generation_step_io_v1"
|
step_io_schema_version: str = "module_generation_step_io_v1"
|
||||||
|
expected_flow_version: str | None = None
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from enum import Enum
|
|||||||
|
|
||||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||||
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 50
|
||||||
|
|
||||||
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||||
|
|||||||
@@ -38,17 +38,28 @@ RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
|
|||||||
"""来自 chat_generation_tasks 表的模块集合。"""
|
"""来自 chat_generation_tasks 表的模块集合。"""
|
||||||
|
|
||||||
|
|
||||||
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
|
RECENT_GENERATION_MODULE_TO_TASK_MODES: dict[RecentGenerationModuleEnum, tuple[GenerationMode, ...]] = {
|
||||||
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC,
|
RecentGenerationModuleEnum.CHAT_AI: (
|
||||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
|
GenerationMode.CHATAPI_ASYNC,
|
||||||
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
|
GenerationMode.CHATAPI_CHILD,
|
||||||
|
),
|
||||||
|
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
|
||||||
|
RecentGenerationModuleEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
|
||||||
}
|
}
|
||||||
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。"""
|
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 集合的映射。"""
|
||||||
|
|
||||||
|
|
||||||
|
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
|
||||||
|
module: task_modes[0]
|
||||||
|
for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
|
||||||
|
}
|
||||||
|
"""兼容旧调用的单一任务模式映射。"""
|
||||||
|
|
||||||
|
|
||||||
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
|
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
|
||||||
task_mode.value: module
|
task_mode.value: module
|
||||||
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items()
|
for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
|
||||||
|
for task_mode in task_modes
|
||||||
}
|
}
|
||||||
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
|
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ShotReplicateStepIOSchemaVersionEnum(StrEnum):
|
|||||||
"""拆镜复刻子任务 input_json/output_json 结构版本。"""
|
"""拆镜复刻子任务 input_json/output_json 结构版本。"""
|
||||||
|
|
||||||
V1 = "shot_replicate_step_io_v1"
|
V1 = "shot_replicate_step_io_v1"
|
||||||
|
V2 = "shot_replicate_step_io_v2"
|
||||||
|
|
||||||
|
|
||||||
class ShotTaskSetStatusEnum(StrEnum):
|
class ShotTaskSetStatusEnum(StrEnum):
|
||||||
@@ -117,6 +118,7 @@ class ShotReplicateLogEventEnum(StrEnum):
|
|||||||
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
|
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
|
||||||
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
|
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
|
||||||
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
|
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
|
||||||
|
ANALYSIS_STALE_ATTEMPT_SKIPPED = "SHOT_ANALYSIS_STALE_ATTEMPT_SKIPPED"
|
||||||
|
|
||||||
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
|
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
|
||||||
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
|
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
|
||||||
@@ -130,6 +132,7 @@ class ShotReplicateLogEventEnum(StrEnum):
|
|||||||
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
|
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
|
||||||
|
|
||||||
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
|
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
|
||||||
|
SPLIT_SUMMARY_REPAIRED = "SHOT_SPLIT_SUMMARY_REPAIRED"
|
||||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
VIDEO_UPSCALE_CONFIG_KEY = "video_upscale_config"
|
||||||
|
VIDEO_UPSCALE_CONFIG_DESCRIPTION = "视频生成超分全局配置"
|
||||||
|
VIDEO_UPSCALE_CONFIG_VERSION = 1
|
||||||
|
VIDEO_UPSCALE_SOURCE_RETAINED_MARKER = "retained_by_snapshot_config"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUpscaleProcessorKey(str, Enum):
|
||||||
|
LOCAL_FFMPEG_CROP_V1 = "local_ffmpeg_crop_v1"
|
||||||
|
VOLC_LARGE_MODEL_V1 = "volc_large_model_v1"
|
||||||
|
VOLC_STANDARD_V1 = "volc_standard_v1"
|
||||||
|
VOLC_PROFESSIONAL_V1 = "volc_professional_v1"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUpscaleTaskStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
RETRY_WAITING = "retry_waiting"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUpscaleStage(str, Enum):
|
||||||
|
QUEUED = "upscale_queued"
|
||||||
|
SOURCE_READY = "upscale_source_ready"
|
||||||
|
LOCAL_PROCESSING = "upscale_local_processing"
|
||||||
|
REMOTE_SUBMITTING = "upscale_remote_submitting"
|
||||||
|
REMOTE_POLLING = "upscale_remote_polling"
|
||||||
|
RESULT_READY = "upscale_result_ready"
|
||||||
|
RESULT_DOWNLOADING = "upscale_result_downloading"
|
||||||
|
VALIDATING = "upscale_validating"
|
||||||
|
GENERATING_COVER = "upscale_generating_cover"
|
||||||
|
FINALIZING = "upscale_finalizing"
|
||||||
|
RETRY_WAITING = "upscale_retry_waiting"
|
||||||
|
COMPLETED = "upscale_completed"
|
||||||
|
FAILED = "upscale_failed"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUpscaleInputSourceType(str, Enum):
|
||||||
|
PROVIDER_REMOTE = "provider_remote"
|
||||||
|
LOCAL_SIGNED = "local_signed"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUpscaleProbeStatus(str, Enum):
|
||||||
|
NOT_CHECKED = "not_checked"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
UNPARSABLE = "unparsable"
|
||||||
|
|
||||||
|
|
||||||
|
VIDEO_UPSCALE_RESOLUTIONS = ("480p", "720p", "1080p", "2K", "4K")
|
||||||
|
VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE = {
|
||||||
|
"480p": 480,
|
||||||
|
"720p": 720,
|
||||||
|
"1080p": 1080,
|
||||||
|
"2K": 1440,
|
||||||
|
"4K": 2160,
|
||||||
|
}
|
||||||
|
VIDEO_UPSCALE_RESOLUTION_RANK = {
|
||||||
|
resolution: index for index, resolution in enumerate(VIDEO_UPSCALE_RESOLUTIONS, start=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_video_upscale_resolution(value: str | None) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
normalized = text.lower()
|
||||||
|
aliases = {
|
||||||
|
"480p": "480p",
|
||||||
|
"720p": "720p",
|
||||||
|
"1080p": "1080p",
|
||||||
|
"2k": "2K",
|
||||||
|
"4k": "4K",
|
||||||
|
}
|
||||||
|
return aliases.get(normalized, text)
|
||||||
|
|
||||||
|
|
||||||
|
def video_upscale_short_edge_pixels(value: str | None) -> int:
|
||||||
|
normalized = normalize_video_upscale_resolution(value)
|
||||||
|
try:
|
||||||
|
return VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE[normalized]
|
||||||
|
except KeyError as exc:
|
||||||
|
supported = "、".join(VIDEO_UPSCALE_RESOLUTIONS)
|
||||||
|
raise ValueError(f"不支持的视频超分分辨率: {value},仅支持 {supported}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_PROCESSOR_KEYS = {VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value}
|
||||||
|
REMOTE_PROCESSOR_KEYS = {
|
||||||
|
VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value,
|
||||||
|
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
|
||||||
|
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
|
||||||
|
}
|
||||||
|
ALL_PROCESSOR_KEYS = LOCAL_PROCESSOR_KEYS | REMOTE_PROCESSOR_KEYS
|
||||||
@@ -12,6 +12,7 @@ from app.config import settings
|
|||||||
from app.models import init_database, close_database
|
from app.models import init_database, close_database
|
||||||
from app.utils.redis import init_redis, close_redis
|
from app.utils.redis import init_redis, close_redis
|
||||||
from app.api.v1 import api_router
|
from app.api.v1 import api_router
|
||||||
|
from app.api.v2 import api_router_v2
|
||||||
from app.middleware.logging import RequestLoggingMiddleware
|
from app.middleware.logging import RequestLoggingMiddleware
|
||||||
from app.middleware.anti_crawler import AntiCrawlerMiddleware
|
from app.middleware.anti_crawler import AntiCrawlerMiddleware
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
@@ -31,11 +32,6 @@ async def lifespan(app: FastAPI):
|
|||||||
await init_redis()
|
await init_redis()
|
||||||
# await _seed_data()
|
# await _seed_data()
|
||||||
|
|
||||||
# Start task queue (handles both video and image generation)
|
|
||||||
from app.services.video_queue import task_queue
|
|
||||||
await task_queue.recover()
|
|
||||||
queue_task = asyncio.create_task(task_queue.run())
|
|
||||||
|
|
||||||
# Background task: auto-expire pending payment orders and sync status
|
# Background task: auto-expire pending payment orders and sync status
|
||||||
async def _order_expiry_loop():
|
async def _order_expiry_loop():
|
||||||
from app.services.payment import expire_all_pending_orders, sync_pending_orders
|
from app.services.payment import expire_all_pending_orders, sync_pending_orders
|
||||||
@@ -104,8 +100,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
task_queue.stop()
|
|
||||||
await queue_task
|
|
||||||
upload_queue.stop()
|
upload_queue.stop()
|
||||||
await upload_queue_task
|
await upload_queue_task
|
||||||
pre_test_queue.stop()
|
pre_test_queue.stop()
|
||||||
@@ -191,6 +185,13 @@ async def _seed_data():
|
|||||||
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
|
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
|
||||||
# Operation manual
|
# Operation manual
|
||||||
("operation_manual", "", "操作手册链接"),
|
("operation_manual", "", "操作手册链接"),
|
||||||
|
("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
|
||||||
|
("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"),
|
||||||
|
("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"),
|
||||||
|
("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"),
|
||||||
|
("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"),
|
||||||
|
("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"),
|
||||||
|
("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"),
|
||||||
]
|
]
|
||||||
for key, value, desc in configs:
|
for key, value, desc in configs:
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
@@ -293,6 +294,20 @@ async def _seed_data():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 文字模型媒体使用 base64 开关
|
||||||
|
existing_media_format = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
|
||||||
|
)
|
||||||
|
if not existing_media_format.scalar_one_or_none():
|
||||||
|
db.add(
|
||||||
|
SystemConfig(
|
||||||
|
id=generate_id(),
|
||||||
|
key="llm_media_as_base64",
|
||||||
|
value="true",
|
||||||
|
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Seed credit ratios - model_config_id is kept as a compatible field name,
|
# Seed credit ratios - model_config_id is kept as a compatible field name,
|
||||||
# but now stores the actual engine id:
|
# but now stores the actual engine id:
|
||||||
# - gen_type=video -> video_engines.id
|
# - gen_type=video -> video_engines.id
|
||||||
@@ -301,7 +316,7 @@ async def _seed_data():
|
|||||||
|
|
||||||
default_video_engine_result = await db.execute(
|
default_video_engine_result = await db.execute(
|
||||||
select(VideoEngine)
|
select(VideoEngine)
|
||||||
.where(VideoEngine.is_active == True)
|
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||||
.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
|
.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -334,7 +349,7 @@ async def _seed_data():
|
|||||||
|
|
||||||
default_image_engine_result = await db.execute(
|
default_image_engine_result = await db.execute(
|
||||||
select(ImageEngine)
|
select(ImageEngine)
|
||||||
.where(ImageEngine.is_active == True)
|
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||||
.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
|
.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -538,6 +553,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
application.include_router(api_router, prefix="/api")
|
application.include_router(api_router, prefix="/api")
|
||||||
|
application.include_router(api_router_v2, prefix="/api/v2")
|
||||||
|
|
||||||
# Static files for uploads
|
# Static files for uploads
|
||||||
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
|
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user